Skip to main content

repose_render_wgpu/
lib.rs

1use std::any::TypeId;
2use std::borrow::Cow;
3use std::collections::{HashMap, HashSet};
4use std::hash::Hash;
5use std::num::NonZeroU64;
6#[cfg(feature = "winit-surface")]
7use std::panic::{AssertUnwindSafe, catch_unwind};
8use std::sync::{Arc, Mutex, Weak};
9
10use repose_core::color::{ChromaSiting, ColorInfo, PixelFormat};
11use repose_core::{
12    Brush, FontStyle, GradientStops, ImageAlignment, ImageFilter, ImageFit, ImagePaintStyle,
13    ImageSourceRect, MAX_GRADIENT_STOPS, Scene, SceneNode, StrokeCap, TextPaintStyle, Transform,
14    Vec2,
15};
16#[cfg(feature = "winit-surface")]
17use repose_core::{GlyphRasterConfig, PresentModePref, RenderBackend, request_frame};
18#[cfg(feature = "winit-surface")]
19use wgpu::Instance;
20
21mod slug;
22
23fn align_up(value: u64, alignment: u64) -> anyhow::Result<u64> {
24    if !alignment.is_power_of_two() {
25        anyhow::bail!("alignment must be a power of two");
26    }
27    value
28        .checked_add(alignment - 1)
29        .map(|v| v & !(alignment - 1))
30        .ok_or_else(|| anyhow::anyhow!("alignment overflow"))
31}
32
33mod commands;
34pub use commands::RenderCommandFailure;
35pub use commands::RenderCommandReport;
36pub use commands::apply_render_commands;
37
38pub mod offscreen;
39
40mod callback;
41pub use callback::{
42    Callback, CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback,
43};
44
45mod depth_composite;
46pub use depth_composite::{BlitRenderPass, DepthComposite};
47
48#[derive(Clone)]
49struct UploadRing {
50    buf: wgpu::Buffer,
51    cap: u64,
52    head: u64,
53    usage: wgpu::BufferUsages,
54}
55
56impl UploadRing {
57    fn new(device: &wgpu::Device, label: &str, cap: u64, usage: wgpu::BufferUsages) -> Self {
58        let max = device.limits().max_buffer_size;
59        let cap = cap.max(4).min(max).min(MAX_UPLOAD_RING_BYTES).max(4);
60        let buf = device.create_buffer(&wgpu::BufferDescriptor {
61            label: Some(label),
62            size: cap,
63            usage,
64            mapped_at_creation: false,
65        });
66        Self {
67            buf,
68            cap,
69            head: 0,
70            usage,
71        }
72    }
73
74    fn reset(&mut self) {
75        self.head = 0;
76    }
77
78    fn grow_to_fit(
79        &mut self,
80        device: &wgpu::Device,
81        encoder: &mut wgpu::CommandEncoder,
82        needed: u64,
83    ) -> anyhow::Result<()> {
84        if needed == 0 {
85            return Ok(());
86        }
87        let start = align_up(self.head, 4)?;
88        let end = start
89            .checked_add(needed)
90            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
91        if end <= self.cap {
92            return Ok(());
93        }
94        let required = align_up(end, 4)?;
95        let doubled = self.cap.checked_mul(2).unwrap_or(0);
96        let new_cap = required.max(doubled).max(256);
97        let new_cap = align_up(new_cap, 4)?;
98        if new_cap > device.limits().max_buffer_size || new_cap > MAX_UPLOAD_RING_BYTES {
99            anyhow::bail!("upload ring exceeds resource budget");
100        }
101        if !self.usage.contains(wgpu::BufferUsages::COPY_SRC) {
102            anyhow::bail!("upload ring growth requires COPY_SRC");
103        }
104        let old = self.buf.clone();
105        self.buf = device.create_buffer(&wgpu::BufferDescriptor {
106            label: Some("upload ring (grown)"),
107            size: new_cap,
108            usage: self.usage,
109            mapped_at_creation: false,
110        });
111        self.cap = new_cap;
112        if self.head > 0 {
113            let copy_size = align_up(self.head, 4)?;
114            if copy_size > old.size() {
115                anyhow::bail!("upload ring old data exceeds old buffer");
116            }
117            encoder.copy_buffer_to_buffer(&old, 0, &self.buf, 0, copy_size);
118        }
119        Ok(())
120    }
121
122    fn alloc_write(&mut self, queue: &wgpu::Queue, bytes: &[u8]) -> anyhow::Result<u64> {
123        let len = u64::try_from(bytes.len())
124            .map_err(|_| anyhow::anyhow!("upload byte length exceeds u64"))?;
125        let start = align_up(self.head, 4)?;
126        let end = start
127            .checked_add(len)
128            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
129        if end > self.cap {
130            anyhow::bail!(
131                "upload ring overflow: start={start} len={len} cap={}",
132                self.cap
133            );
134        }
135        queue.write_buffer(&self.buf, start, bytes);
136        self.head = end;
137        Ok(start)
138    }
139}
140
141struct InstancedPipe<I: bytemuck::Pod> {
142    ring: UploadRing,
143    _marker: std::marker::PhantomData<I>,
144}
145
146impl<I: bytemuck::Pod> InstancedPipe<I> {
147    fn new(ring: UploadRing) -> Self {
148        Self {
149            ring,
150            _marker: std::marker::PhantomData,
151        }
152    }
153
154    fn upload(
155        &mut self,
156        device: &wgpu::Device,
157        queue: &wgpu::Queue,
158        encoder: &mut wgpu::CommandEncoder,
159        data: &[I],
160    ) -> Option<(u64, u32)> {
161        if data.is_empty() {
162            return None;
163        }
164        let count = u32::try_from(data.len()).ok()?;
165        let bytes = bytemuck::cast_slice(data);
166        let byte_len = u64::try_from(bytes.len()).ok()?;
167        if byte_len == 0 || byte_len > device.limits().max_buffer_size {
168            return None;
169        }
170        self.ring
171            .grow_to_fit(device, encoder, byte_len)
172            .map_err(|error| log::error!("{error:#}"))
173            .ok()?;
174        let off = self
175            .ring
176            .alloc_write(queue, bytes)
177            .map_err(|error| log::error!("{error:#}"))
178            .ok()?;
179        Some((off, count))
180    }
181
182    fn reset(&mut self) {
183        self.ring.reset();
184    }
185}
186
187#[repr(C)]
188#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
189struct Globals {
190    ndc_to_px: [f32; 2],
191    _pad: [f32; 2],
192}
193
194fn make_globals(target_w: f32, target_h: f32) -> Globals {
195    Globals {
196        ndc_to_px: [target_w * 0.5, target_h * 0.5],
197        _pad: [0.0, 0.0],
198    }
199}
200
201pub struct WgpuSceneRenderer {
202    pub device: wgpu::Device,
203    pub queue: wgpu::Queue,
204    pub output_format: wgpu::TextureFormat,
205    /// Texture format of the surface the multisampled attachment resolves into.
206    surface_format: wgpu::TextureFormat,
207    pub output_width: u32,
208    pub output_height: u32,
209    /// Pixels per point (DPI scale) for `ScreenDescriptor` / `PaintCallbackInfo`.
210    pub pixels_per_point: f32,
211
212    // Render pipelines. Two sets: one for the MSAA surface pass, one for
213    // graphics-layer render-to-texture passes (sample_count = 1).
214    surface_pipes: Pipelines,
215    working_space_pipes: Pipelines,
216    layer_pipes: Pipelines,
217    working_space_layer_pipes: Pipelines,
218
219    // Instanced draw rings
220    rects: InstancedPipe<RectInstance>,
221    borders: InstancedPipe<BorderInstance>,
222    ellipses: InstancedPipe<EllipseInstance>,
223    ellipse_borders: InstancedPipe<EllipseBorderInstance>,
224    arcs: InstancedPipe<ArcInstance>,
225    glyph_mask: InstancedPipe<GlyphInstance>,
226    glyph_color: InstancedPipe<GlyphInstance>,
227
228    // Image bind layouts and shared sampler
229    image_bind_layout_rgba: wgpu::BindGroupLayout,
230    image_bind_layout_nv12: wgpu::BindGroupLayout,
231    image_sampler: wgpu::Sampler,
232    image_sampler_nearest: wgpu::Sampler,
233    image_sampler_repeat: wgpu::Sampler,
234    image_sampler_repeat_nearest: wgpu::Sampler,
235    layer_sampler: wgpu::Sampler,
236    layer_sampler_linear: wgpu::Sampler,
237
238    // Blur composite ring (for graphics-layer drop shadows)
239    blur_ring: UploadRing,
240
241    text_bind_layout: wgpu::BindGroupLayout,
242
243    // Stencil clip ring
244    clip_ring: UploadRing,
245
246    // Projective layer-composite ring (one ProjectiveInstance per flattened
247    // perspective subtree)
248    projective_ring: UploadRing,
249
250    // Backdrop-blend composite ring (one BlendInstance per isolated blend)
251    blend_ring: UploadRing,
252
253    // Tessellated vector glyph pipeline (always enabled)
254    slug_enabled: bool,
255    slug_ring: UploadRing,
256    slug_cache: slug::GlyphSlugCache,
257    slug_draw_cache: HashMap<SlugDrawKey, SlugDrawCacheEntry>,
258    slug_draw_cache_bytes_total: u64,
259
260    // Instanced NV12 ring
261    nv12: InstancedPipe<Nv12Instance>,
262
263    mesh_cache: HashMap<MeshCacheKey, MeshCacheEntry>,
264    mesh_cache_bytes_total: u64,
265    mesh_uniform_buf: wgpu::Buffer,
266    mesh_bind: wgpu::BindGroup,
267    mesh_uniform_head: u64,
268    mesh_uniform_alignment: u64,
269    mesh_uniform_cap: u64,
270
271    /// Translator-owned flatten layer ids used by the previous frame;
272    /// drained from the layer pool at the start of each translation (they
273    /// are single-frame by construction).
274    flatten_layer_ids: Vec<u32>,
275
276    /// Layer ids handed out to separable-blur scratches, keyed by
277    /// `(source layer, slot)` so they survive the stale-layer sweep and stay
278    /// bound across frames. `blur_scratch_next` is the handout counter for
279    /// those ids; it only ever grows, so a freed slot's id is never reissued.
280    blur_scratch_ids: HashMap<(u32, u32), u32>,
281    blur_scratch_next: u32,
282
283    /// Backdrop snapshots keyed by isolated-blend layer id. Filled during
284    /// translation (texture allocated) and populated by a texture copy at
285    /// execution time, before the blend composite draws.
286    blend_snapshots: std::collections::HashMap<u32, BlendSnapshot>,
287    blend_snapshot_pool: HashMap<BlendSnapshotKey, BlendSnapshot>,
288    /// (blend layer id, parent target) copies to run before the pass that
289    /// composites the blend. Executed between passes: copies the current
290    /// target region into the snapshot texture.
291    blend_copies: Vec<BlendCopy>,
292
293    msaa_samples: u32,
294    working_space_msaa_samples: u32,
295
296    // Depth-stencil target
297    depth_stencil_tex: wgpu::Texture,
298    depth_stencil_view: wgpu::TextureView,
299
300    // Optional MSAA color target
301    msaa_tex: Option<wgpu::Texture>,
302    msaa_view: Option<wgpu::TextureView>,
303    ws_msaa_tex: Option<wgpu::Texture>,
304    ws_msaa_view: Option<wgpu::TextureView>,
305    surface_resolve_tex: Option<wgpu::Texture>,
306    surface_resolve_view: Option<wgpu::TextureView>,
307    surface_resolve_bytes: u64,
308    msaa_bytes: u64,
309    ws_msaa_bytes: u64,
310    depth_stencil_bytes: u64,
311    working_space_bytes: u64,
312    gpu_budget_bytes: u64,
313
314    globals_buf: wgpu::Buffer,
315    globals_bind: wgpu::BindGroup,
316    globals_staging: wgpu::Buffer,
317    globals_staging_cap: u64,
318
319    // Glyph atlas
320    atlas_mask: AtlasA8,
321    atlas_color: AtlasRGBA,
322    atlas_mask_bind: wgpu::BindGroup,
323    atlas_color_bind: wgpu::BindGroup,
324    atlas_generation: u64,
325    atlas_mask_pending: AtlasPending,
326    atlas_color_pending: AtlasPending,
327    atlas_last_compact_frame: u64,
328    atlas_mask_failures: HashSet<repose_text::CacheKey>,
329    atlas_color_failures: HashSet<repose_text::CacheKey>,
330    /// Glyphs that rasterized fine but had no room. Skipped until the next
331    /// repack so a full atlas does not re-rasterize every frame.
332    atlas_mask_deferred: HashSet<repose_text::CacheKey>,
333    atlas_color_deferred: HashSet<repose_text::CacheKey>,
334
335    glyph_outline_cache: HashMap<repose_text::CacheKey, GlyphOutlineEntry>,
336    legacy_shaped_text_cache: HashMap<TextShapeKey, TextShapeCacheEntry>,
337
338    // Image management
339    next_image_handle: u64,
340    images: HashMap<u64, ImageTex>,
341    retained: HashMap<u64, RetainedImage>,
342    retained_bytes_total: u64,
343
344    // A8 coverage-tile management (host-rasterized masks composited tinted;
345    // no retained CPU copies - tiles are immutable and re-registered).
346    next_coverage_handle: u64,
347    coverages: HashMap<u64, CoverageTex>,
348
349    // Eviction stats
350    frame_index: u64,
351    cache_touch_clock: u64,
352    image_bytes_total: u64,
353    image_evict_after_frames: u64,
354    image_budget_bytes: u64,
355
356    // Graphics layer pool. Maps `SceneNode::BeginLayer::layer_id` to a
357    // cached offscreen render target.
358    layer_pool: HashMap<u32, LayerTarget>,
359    producer_layer_ids: HashSet<u32>,
360    layer_bytes_total: u64,
361    transient_layer_bytes_total: u64,
362
363    // Linear working-space mode (default off -> fast playback path).
364    // When enabled, the scene is rendered into an Rgba16Float intermediate
365    // texture, then a final full-screen pass applies the display OETF.
366    working_space: bool,
367    ws_tex: Option<wgpu::Texture>,
368    ws_view: Option<wgpu::TextureView>,
369    ws_bind: Option<wgpu::BindGroup>,
370    display_pipeline: Option<wgpu::RenderPipeline>,
371    display_layout: Option<wgpu::BindGroupLayout>,
372
373    pub callback_resources: CallbackResources,
374    callback_scoped_resources: HashMap<CallbackScopeKey, CallbackResources>,
375    callback_scope_uses: HashMap<CallbackScopeKey, CallbackScopeUse>,
376    callback_scope_payloads: HashMap<CallbackScopeKey, Weak<Callback>>,
377    callback_scope_clock: u64,
378    blend_snapshot_bytes_total: u64,
379    frame_active: bool,
380    last_render_error: Option<String>,
381}
382
383pub struct WgpuSurfaceBackend {
384    #[cfg(feature = "winit-surface")]
385    instance: Option<wgpu::Instance>,
386    #[cfg(feature = "winit-surface")]
387    window: Option<Arc<winit::window::Window>>,
388    pub surface: Option<wgpu::Surface<'static>>,
389    pub surface_config: Option<wgpu::SurfaceConfiguration>,
390    #[cfg_attr(not(feature = "winit-surface"), allow(dead_code))]
391    pending_reconfigure: bool,
392    pub renderer: WgpuSceneRenderer,
393    device_lost: Arc<Mutex<Option<(wgpu::DeviceLostReason, String)>>>,
394}
395
396impl std::ops::Deref for WgpuSurfaceBackend {
397    type Target = WgpuSceneRenderer;
398    fn deref(&self) -> &Self::Target {
399        &self.renderer
400    }
401}
402impl std::ops::DerefMut for WgpuSurfaceBackend {
403    fn deref_mut(&mut self) -> &mut Self::Target {
404        &mut self.renderer
405    }
406}
407
408#[cfg(feature = "winit-surface")]
409pub type WgpuBackend = WgpuSurfaceBackend;
410
411/// Largest `(width, height)` not exceeding `max` on either axis while keeping
412/// the requested aspect ratio.
413///
414/// Clamping the two axes independently rescales them by different factors, so
415/// "CSS pixels to viewport pixels" stops being a single number: the scene is
416/// projected against one shape while input is resolved against another, which
417/// shows up as a touch offset that grows with distance from the origin. One
418/// uniform factor keeps layout, hit testing and the framebuffer on the same
419/// scale, leaving a uniform loss of resolution.
420pub fn clamp_output_size(width: u32, height: u32, max: u32) -> (u32, u32) {
421    if width == 0 || height == 0 {
422        return (0, 0);
423    }
424    let max = max.max(1);
425    if width <= max && height <= max {
426        return (width, height);
427    }
428    let scale = max as f64 / width.max(height) as f64;
429    let clamped = |v: u32| ((v as f64 * scale).round() as u32).clamp(1, max);
430    (clamped(width), clamped(height))
431}
432
433/// Compose/Skia radius -> gaussian sigma (the `1 / sqrt(3)` the high-quality
434/// software mask blur applies).
435fn blur_sigma(radius: f32) -> f32 {
436    (radius.max(0.0) * 0.577_350_3 + 0.5).max(0.0)
437}
438
439/// Padding needed on each side of a layer for a separable blur to fit its
440/// 3-sigma support without clipping the kernel.
441fn blur_pad(radius: f32) -> f32 {
442    (blur_sigma(radius) * 3.0).ceil().max(0.0)
443}
444
445/// First translator-owned blur-scratch layer id. Producer ids start at 1 per
446/// scene and flatten owns `FLATTEN_ID_BASE`, so this range never collides.
447const BLUR_SCRATCH_ID_BASE: u32 = 0xE000_0000;
448
449/// Scratch slot for the `Modifier::blur` half of a layer's content.
450const BLUR_SLOT_CONTENT: u32 = 0;
451/// Scratch slot for the shadow's half-plane kernel, so a layer can carry a
452/// content blur and a shadow without either pass clobbering the other's
453/// scratch.
454const BLUR_SLOT_SHADOW: u32 = 1;
455
456impl Drop for WgpuSceneRenderer {
457    fn drop(&mut self) {
458        let _ = self.device.poll(wgpu::PollType::Poll);
459        #[cfg(not(target_arch = "wasm32"))]
460        {
461            let _ = self.device.poll(wgpu::PollType::Wait {
462                submission_index: None,
463                timeout: Some(web_time::Duration::from_millis(100)),
464            });
465        }
466    }
467}
468
469#[derive(Clone)]
470struct LayerTarget {
471    texture: wgpu::Texture,
472    view: wgpu::TextureView,
473    bind: wgpu::BindGroup,
474    bind_linear: wgpu::BindGroup,
475    depth_stencil_view: wgpu::TextureView,
476    width: u32,
477    height: u32,
478    format: wgpu::TextureFormat,
479    bytes: u64,
480    rect_px: (f32, f32, f32, f32),
481    transient: bool,
482}
483
484/// Backdrop snapshot for one isolated blend: a copy of the current target
485/// region taken before the source layer is composited, sampled as the
486/// "backdrop" input of the blend shader.
487#[derive(Clone, Copy, PartialEq, Eq, Hash)]
488struct BlendSnapshotKey {
489    format: wgpu::TextureFormat,
490    width: u32,
491    height: u32,
492}
493
494#[derive(Clone)]
495struct BlendSnapshot {
496    key: BlendSnapshotKey,
497    texture: wgpu::Texture,
498    bind: wgpu::BindGroup,
499    bytes: u64,
500}
501
502struct BlendCopy {
503    pass_id: u64,
504    blend_id: u32,
505    target: PassTarget,
506    region: repose_core::Rect,
507}
508
509#[derive(Clone)]
510enum ActiveClip {
511    Rect {
512        off: u64,
513        cnt: u32,
514        rect: repose_core::Rect,
515        radii: [f32; 4],
516        /// Scissor the matching push drew its stencil pass with. The pop must
517        /// reuse it: a different (wider) bound decrements stencil the push
518        /// never incremented, leaving those pixels a level below the base so
519        /// every later `Equal` draw drops them.
520        scissor: (u32, u32, u32, u32),
521        difference: bool,
522        applied: bool,
523        blocked: bool,
524    },
525    Vector {
526        mesh: Option<MeshGpu>,
527        source: Arc<repose_core::VectorMeshData>,
528        uoff: u64,
529        affine: [f32; 6],
530        scissor: (u32, u32, u32, u32),
531        difference: bool,
532        applied: bool,
533        blocked: bool,
534    },
535}
536
537impl ActiveClip {
538    fn difference(&self) -> bool {
539        match self {
540            Self::Rect { difference, .. } | Self::Vector { difference, .. } => *difference,
541        }
542    }
543
544    fn blocked(&self) -> bool {
545        match self {
546            Self::Rect { blocked, .. } | Self::Vector { blocked, .. } => *blocked,
547        }
548    }
549}
550
551/// Identifies which render target a `Pass` draws into.
552#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
553enum PassTarget {
554    Surface,
555    Layer(u32),
556}
557
558#[derive(Clone, Debug, PartialEq, Eq, Hash)]
559struct CallbackIdentity {
560    type_id: TypeId,
561    /// The callback's own key, kept whole: two colliding digests must not
562    /// share a resource bucket.
563    key: Option<Arc<str>>,
564    reusable: bool,
565}
566
567/// Identifies one callback's `CallbackResources` bucket. Deliberately
568/// excludes the target's size and scale: `prepare` runs every frame with
569/// the current [`ScreenDescriptor`], so any size-dependent resource
570/// belongs in the callback's own `ensure`. Keying the bucket by size
571/// would drop every callback's GPU state on a window resize - atlas
572/// contents, pipeline caches - leaving nothing to rebuild from.
573///
574/// A bucket still changes identity with the target, format or sample
575/// count, and is evicted once [`MAX_CALLBACK_SCOPES`] is exceeded, so
576/// anything loaded once in `prepare` has to be rebuildable from
577/// `prepare` alone.
578#[derive(Clone, Debug, PartialEq, Eq, Hash)]
579struct CallbackScopeKey {
580    callback: CallbackIdentity,
581    target: PassTarget,
582    target_format: wgpu::TextureFormat,
583    sample_count: u32,
584}
585
586#[derive(Clone, Copy)]
587struct CallbackScopeUse {
588    tick: u64,
589    frame: u64,
590}
591
592fn callback_identity(callback: &Callback, fallback: usize) -> CallbackIdentity {
593    match callback.0.resource_key() {
594        Some(key) => CallbackIdentity {
595            type_id: callback.0.resource_type_id(),
596            key: Some(Arc::from(key)),
597            reusable: true,
598        },
599        None => CallbackIdentity {
600            type_id: TypeId::of::<Callback>(),
601            key: Some(Arc::from(fallback.to_string())),
602            reusable: false,
603        },
604    }
605}
606
607fn callback_scope_key(
608    callback: &Callback,
609    fallback: usize,
610    target: PassTarget,
611    descriptor: &ScreenDescriptor,
612) -> CallbackScopeKey {
613    CallbackScopeKey {
614        callback: callback_identity(callback, fallback),
615        target,
616        target_format: descriptor.target_format,
617        sample_count: descriptor.sample_count,
618    }
619}
620
621/// Blend modes the GPU can express with fixed-function blending (plus
622/// [`Alpha`](repose_core::BlendMode::Alpha), the premultiplied default). Every
623/// other mode isolates into a graphics layer and composites with the
624/// `blend_layer` shader.
625const FIXED_FUNCTION_BLENDS: [repose_core::BlendMode; 5] = [
626    repose_core::BlendMode::Add,
627    repose_core::BlendMode::Multiply,
628    repose_core::BlendMode::Screen,
629    repose_core::BlendMode::Darken,
630    repose_core::BlendMode::Lighten,
631];
632
633/// Hardware blend state for a [`FIXED_FUNCTION_BLENDS`] mode, or `None` for
634/// `Alpha` and every mode that needs an isolated backdrop pass. Shared by the
635/// mesh and image pipelines so both composite identically.
636fn fixed_function_blend(mode: repose_core::BlendMode) -> Option<wgpu::BlendState> {
637    let premult_alpha = wgpu::BlendComponent::OVER;
638    let additive = wgpu::BlendComponent {
639        src_factor: wgpu::BlendFactor::One,
640        dst_factor: wgpu::BlendFactor::One,
641        operation: wgpu::BlendOperation::Add,
642    };
643    Some(match mode {
644        repose_core::BlendMode::Add => wgpu::BlendState {
645            color: additive,
646            alpha: additive,
647        },
648        repose_core::BlendMode::Multiply => wgpu::BlendState {
649            color: wgpu::BlendComponent {
650                src_factor: wgpu::BlendFactor::Dst,
651                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
652                operation: wgpu::BlendOperation::Add,
653            },
654            alpha: premult_alpha,
655        },
656        repose_core::BlendMode::Screen => wgpu::BlendState {
657            color: wgpu::BlendComponent {
658                src_factor: wgpu::BlendFactor::One,
659                dst_factor: wgpu::BlendFactor::OneMinusSrc,
660                operation: wgpu::BlendOperation::Add,
661            },
662            alpha: premult_alpha,
663        },
664        repose_core::BlendMode::Darken => wgpu::BlendState {
665            color: wgpu::BlendComponent {
666                src_factor: wgpu::BlendFactor::One,
667                dst_factor: wgpu::BlendFactor::One,
668                operation: wgpu::BlendOperation::Min,
669            },
670            alpha: premult_alpha,
671        },
672        repose_core::BlendMode::Lighten => wgpu::BlendState {
673            color: wgpu::BlendComponent {
674                src_factor: wgpu::BlendFactor::One,
675                dst_factor: wgpu::BlendFactor::One,
676                operation: wgpu::BlendOperation::Max,
677            },
678            alpha: premult_alpha,
679        },
680        _ => return None,
681    })
682}
683
684/// A bundle of render pipelines for a single sample-count target. Created
685/// twice: once with `sample_count = msaa_samples` for the surface pass, and
686/// once with `sample_count = 1` for graphics-layer render-to-texture passes
687/// (where MSAA is wasted).
688#[derive(Clone)]
689struct Pipelines {
690    rects: wgpu::RenderPipeline,
691    borders: wgpu::RenderPipeline,
692    ellipses: wgpu::RenderPipeline,
693    ellipse_borders: wgpu::RenderPipeline,
694    arcs: wgpu::RenderPipeline,
695    text_mask: wgpu::RenderPipeline,
696    text_color: wgpu::RenderPipeline,
697    image_rgba: wgpu::RenderPipeline,
698    /// Fixed-function blend variants of `image_rgba`, indexed like
699    /// `FIXED_FUNCTION_BLENDS`. `SceneNode::ImageBlended` with a separable
700    /// mode picks one here; the rest go through isolation.
701    image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
702    /// Tinted A8 coverage composite (`coverage.wgsl`): same vertex
703    /// attributes and bind groups as the text/color path, sampling a
704    /// single-channel tile registered with `register_coverage_a8`.
705    coverage: wgpu::RenderPipeline,
706    image_nv12: wgpu::RenderPipeline,
707    /// Fixed-function blend variants of `image_nv12`, indexed like
708    /// `FIXED_FUNCTION_BLENDS`.
709    image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
710    blur: wgpu::RenderPipeline,
711    blur_content: wgpu::RenderPipeline,
712    clip_bin: wgpu::RenderPipeline,
713    clip_dec: wgpu::RenderPipeline,
714    slug: Option<wgpu::RenderPipeline>,
715    /// Tessellated vector mesh (fill/stroke). Uses an `Equal` stencil compare
716    /// so world content is correctly masked to the active `PushVectorClip`
717    /// shape (outside any clip the stencil is 0 == ref 0, so it draws).
718    mesh: wgpu::RenderPipeline,
719    /// Screen-space overlay meshes: `LessEqual` compare so they always draw
720    /// regardless of any active vector clip.
721    mesh_overlay: wgpu::RenderPipeline,
722    /// Fixed-function blend variants of the mesh pipeline, selected by
723    /// `BlendMode`. Backdrop-dependent modes (overlay, color-dodge/burn,
724    /// hard/soft-light, exclusion, hue/saturation/color/luminosity) use the
725    /// `blend_layer` shader instead.
726    mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
727    /// Backdrop-blend composite: samples an isolated source layer and the
728    /// current target, applies the CSS blend formula selected by a uniform,
729    /// and composites premultiplied source-over.
730    blend_layer: wgpu::RenderPipeline,
731    /// Stencil increment for vector clips.
732    mesh_clip_inc: wgpu::RenderPipeline,
733    /// Stencil decrement for vector clips.
734    mesh_clip_dec: wgpu::RenderPipeline,
735    /// Projective layer composite (perspective flattening): samples a
736    /// graphics-layer texture through a 2D projective map. Drawn with a
737    /// `ProjectiveInstance` from `projective_ring`.
738    projective_layer: wgpu::RenderPipeline,
739}
740
741/// Vertex attributes for an instance buffer, as `(shader_location, byte
742/// offset, format)`. Deriving these by hand let struct field reordering
743/// silently desync the offsets from the layout.
744fn vattrs(spec: &[(u32, u64, wgpu::VertexFormat)]) -> Vec<wgpu::VertexAttribute> {
745    spec.iter()
746        .map(|&(shader_location, offset, format)| wgpu::VertexAttribute {
747            shader_location,
748            offset,
749            format,
750        })
751        .collect()
752}
753
754/// Byte offset of an instance field, so the attribute tables below stay tied
755/// to the struct layout instead of being maintained by hand.
756macro_rules! field_offset {
757    ($ty:ty, $field:ident) => {
758        std::mem::offset_of!($ty, $field) as u64
759    };
760}
761
762impl Pipelines {
763    fn create(
764        device: &wgpu::Device,
765        format: wgpu::TextureFormat,
766        sample_count: u32,
767        globals_layout: &wgpu::BindGroupLayout,
768        text_bind_layout: &wgpu::BindGroupLayout,
769        image_bind_layout_nv12: &wgpu::BindGroupLayout,
770        clip_pipeline_layout: &wgpu::PipelineLayout,
771        stencil_for_content: &wgpu::DepthStencilState,
772        stencil_for_clip_inc: &wgpu::DepthStencilState,
773        stencil_for_clip_dec: &wgpu::DepthStencilState,
774        clip_vertex_layout: &wgpu::VertexBufferLayout,
775        mesh_bind_layout: &wgpu::BindGroupLayout,
776    ) -> Self {
777        let msaa_state = wgpu::MultisampleState {
778            count: sample_count,
779            mask: !0,
780            alpha_to_coverage_enabled: false,
781        };
782
783        macro_rules! make_content_pipeline {
784            ($name:ident, $shader:literal, $inst_type:ty, $attrs:expr) => {
785                let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
786                    label: Some(concat!($shader, ".wgsl")),
787                    source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(concat!(
788                        "shaders/", $shader, ".wgsl"
789                    )))),
790                });
791                let pipeline_layout =
792                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
793                        label: Some(concat!($shader, " pipeline layout")),
794                        bind_group_layouts: &[Some(globals_layout)],
795                        immediate_size: 0,
796                    });
797                let $name = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
798                    label: Some(concat!($shader, " pipeline")),
799                    layout: Some(&pipeline_layout),
800                    vertex: wgpu::VertexState {
801                        module: &shader_module,
802                        entry_point: Some("vs_main"),
803                        buffers: &[Some(wgpu::VertexBufferLayout {
804                            array_stride: std::mem::size_of::<$inst_type>() as u64,
805                            step_mode: wgpu::VertexStepMode::Instance,
806                            attributes: $attrs,
807                        })],
808                        compilation_options: wgpu::PipelineCompilationOptions::default(),
809                    },
810                    fragment: Some(wgpu::FragmentState {
811                        module: &shader_module,
812                        entry_point: Some("fs_main"),
813                        targets: &[Some(wgpu::ColorTargetState {
814                            format,
815                            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
816                            write_mask: wgpu::ColorWrites::ALL,
817                        })],
818                        compilation_options: wgpu::PipelineCompilationOptions::default(),
819                    }),
820                    primitive: wgpu::PrimitiveState::default(),
821                    depth_stencil: Some(stencil_for_content.clone()),
822                    multisample: msaa_state,
823                    multiview_mask: None,
824                    cache: None,
825                });
826            };
827        }
828
829        let rect_attrs = vattrs(&[
830            (
831                0,
832                field_offset!(RectInstance, xywh),
833                wgpu::VertexFormat::Float32x4,
834            ),
835            (
836                1,
837                field_offset!(RectInstance, radii),
838                wgpu::VertexFormat::Float32x4,
839            ),
840            (
841                2,
842                field_offset!(RectInstance, color0),
843                wgpu::VertexFormat::Float32x4,
844            ),
845            (
846                3,
847                field_offset!(RectInstance, color1),
848                wgpu::VertexFormat::Float32x4,
849            ),
850            (
851                4,
852                field_offset!(RectInstance, fwd_mat),
853                wgpu::VertexFormat::Float32x4,
854            ),
855            (
856                5,
857                field_offset!(RectInstance, grad),
858                wgpu::VertexFormat::Float32x4,
859            ),
860            (
861                6,
862                field_offset!(RectInstance, bits),
863                wgpu::VertexFormat::Uint32,
864            ),
865        ]);
866        let border_attrs = vattrs(&[
867            (
868                0,
869                field_offset!(BorderInstance, xywh),
870                wgpu::VertexFormat::Float32x4,
871            ),
872            (
873                1,
874                field_offset!(BorderInstance, radii),
875                wgpu::VertexFormat::Float32x4,
876            ),
877            (
878                2,
879                field_offset!(BorderInstance, color0),
880                wgpu::VertexFormat::Float32x4,
881            ),
882            (
883                3,
884                field_offset!(BorderInstance, color1),
885                wgpu::VertexFormat::Float32x4,
886            ),
887            (
888                4,
889                field_offset!(BorderInstance, fwd_mat),
890                wgpu::VertexFormat::Float32x4,
891            ),
892            (
893                5,
894                field_offset!(BorderInstance, grad),
895                wgpu::VertexFormat::Float32x4,
896            ),
897            (
898                6,
899                field_offset!(BorderInstance, bits),
900                wgpu::VertexFormat::Uint32,
901            ),
902            (
903                7,
904                field_offset!(BorderInstance, stroke),
905                wgpu::VertexFormat::Float32,
906            ),
907        ]);
908        let ellipse_attrs = vattrs(&[
909            (
910                0,
911                field_offset!(EllipseInstance, xywh),
912                wgpu::VertexFormat::Float32x4,
913            ),
914            (
915                1,
916                field_offset!(EllipseInstance, color0),
917                wgpu::VertexFormat::Float32x4,
918            ),
919            (
920                2,
921                field_offset!(EllipseInstance, color1),
922                wgpu::VertexFormat::Float32x4,
923            ),
924            (
925                3,
926                field_offset!(EllipseInstance, fwd_mat),
927                wgpu::VertexFormat::Float32x4,
928            ),
929            (
930                4,
931                field_offset!(EllipseInstance, grad),
932                wgpu::VertexFormat::Float32x4,
933            ),
934            (
935                5,
936                field_offset!(EllipseInstance, bits),
937                wgpu::VertexFormat::Uint32,
938            ),
939        ]);
940        let ellipse_border_attrs = vattrs(&[
941            (
942                0,
943                field_offset!(EllipseBorderInstance, xywh),
944                wgpu::VertexFormat::Float32x4,
945            ),
946            (
947                1,
948                field_offset!(EllipseBorderInstance, color0),
949                wgpu::VertexFormat::Float32x4,
950            ),
951            (
952                2,
953                field_offset!(EllipseBorderInstance, color1),
954                wgpu::VertexFormat::Float32x4,
955            ),
956            (
957                3,
958                field_offset!(EllipseBorderInstance, fwd_mat),
959                wgpu::VertexFormat::Float32x4,
960            ),
961            (
962                4,
963                field_offset!(EllipseBorderInstance, grad),
964                wgpu::VertexFormat::Float32x4,
965            ),
966            (
967                5,
968                field_offset!(EllipseBorderInstance, bits),
969                wgpu::VertexFormat::Uint32,
970            ),
971            (
972                6,
973                field_offset!(EllipseBorderInstance, stroke),
974                wgpu::VertexFormat::Float32,
975            ),
976            (
977                7,
978                field_offset!(EllipseBorderInstance, pad),
979                wgpu::VertexFormat::Float32,
980            ),
981        ]);
982
983        make_content_pipeline!(rects, "rect", RectInstance, &rect_attrs);
984        make_content_pipeline!(borders, "border", BorderInstance, &border_attrs);
985        make_content_pipeline!(ellipses, "ellipse", EllipseInstance, &ellipse_attrs);
986        make_content_pipeline!(
987            ellipse_borders,
988            "ellipse_border",
989            EllipseBorderInstance,
990            &ellipse_border_attrs
991        );
992
993        let arc_attrs = vattrs(&[
994            (
995                0,
996                field_offset!(ArcInstance, xywh),
997                wgpu::VertexFormat::Float32x4,
998            ),
999            (
1000                1,
1001                field_offset!(ArcInstance, color0),
1002                wgpu::VertexFormat::Float32x4,
1003            ),
1004            (
1005                2,
1006                field_offset!(ArcInstance, color1),
1007                wgpu::VertexFormat::Float32x4,
1008            ),
1009            (
1010                3,
1011                field_offset!(ArcInstance, fwd_mat),
1012                wgpu::VertexFormat::Float32x4,
1013            ),
1014            (
1015                4,
1016                field_offset!(ArcInstance, grad),
1017                wgpu::VertexFormat::Float32x4,
1018            ),
1019            (
1020                5,
1021                field_offset!(ArcInstance, bits),
1022                wgpu::VertexFormat::Uint32,
1023            ),
1024            (
1025                6,
1026                field_offset!(ArcInstance, start_angle),
1027                wgpu::VertexFormat::Float32,
1028            ),
1029            (
1030                7,
1031                field_offset!(ArcInstance, sweep_angle),
1032                wgpu::VertexFormat::Float32,
1033            ),
1034            (
1035                8,
1036                field_offset!(ArcInstance, stroke),
1037                wgpu::VertexFormat::Float32,
1038            ),
1039            (
1040                9,
1041                field_offset!(ArcInstance, pad),
1042                wgpu::VertexFormat::Float32,
1043            ),
1044        ]);
1045
1046        make_content_pipeline!(arcs, "arc", ArcInstance, &arc_attrs);
1047
1048        // Text (mask)
1049        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1050            label: Some("text.wgsl"),
1051            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
1052        });
1053        // Text (color)
1054        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1055            label: Some("text_color.wgsl"),
1056            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1057                "shaders/text_color.wgsl"
1058            ))),
1059        });
1060        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1061            label: Some("text pipeline layout"),
1062            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1063            immediate_size: 0,
1064        });
1065        let glyph_vertex = wgpu::VertexBufferLayout {
1066            array_stride: std::mem::size_of::<GlyphInstance>() as u64,
1067            step_mode: wgpu::VertexStepMode::Instance,
1068            attributes: &[
1069                wgpu::VertexAttribute {
1070                    shader_location: 0,
1071                    offset: 0,
1072                    format: wgpu::VertexFormat::Float32x4,
1073                },
1074                wgpu::VertexAttribute {
1075                    shader_location: 1,
1076                    offset: 16,
1077                    format: wgpu::VertexFormat::Float32x4,
1078                },
1079                wgpu::VertexAttribute {
1080                    shader_location: 2,
1081                    offset: 32,
1082                    format: wgpu::VertexFormat::Float32x4,
1083                },
1084                wgpu::VertexAttribute {
1085                    shader_location: 3,
1086                    offset: 48,
1087                    format: wgpu::VertexFormat::Float32x4,
1088                },
1089            ],
1090        };
1091        let text_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1092            label: Some("text pipeline (mask)"),
1093            layout: Some(&text_pipeline_layout),
1094            vertex: wgpu::VertexState {
1095                module: &text_mask_shader,
1096                entry_point: Some("vs_main"),
1097                buffers: &[Some(glyph_vertex.clone())],
1098                compilation_options: wgpu::PipelineCompilationOptions::default(),
1099            },
1100            fragment: Some(wgpu::FragmentState {
1101                module: &text_mask_shader,
1102                entry_point: Some("fs_main"),
1103                targets: &[Some(wgpu::ColorTargetState {
1104                    format,
1105                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1106                    write_mask: wgpu::ColorWrites::ALL,
1107                })],
1108                compilation_options: wgpu::PipelineCompilationOptions::default(),
1109            }),
1110            primitive: wgpu::PrimitiveState::default(),
1111            depth_stencil: Some(stencil_for_content.clone()),
1112            multisample: msaa_state,
1113            multiview_mask: None,
1114            cache: None,
1115        });
1116        let text_color_pipeline =
1117            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1118                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1119                    label: Some(label),
1120                    layout: Some(&text_pipeline_layout),
1121                    vertex: wgpu::VertexState {
1122                        module: &text_color_shader,
1123                        entry_point: Some("vs_main"),
1124                        buffers: &[Some(glyph_vertex.clone())],
1125                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1126                    },
1127                    fragment: Some(wgpu::FragmentState {
1128                        module: &text_color_shader,
1129                        entry_point: Some("fs_main"),
1130                        targets: &[Some(wgpu::ColorTargetState {
1131                            format,
1132                            blend: Some(blend),
1133                            write_mask: wgpu::ColorWrites::ALL,
1134                        })],
1135                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1136                    }),
1137                    primitive: wgpu::PrimitiveState::default(),
1138                    depth_stencil: Some(stencil_for_content.clone()),
1139                    multisample: msaa_state,
1140                    multiview_mask: None,
1141                    cache: None,
1142                })
1143            };
1144        let text_color = text_color_pipeline(
1145            "text pipeline (color)",
1146            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1147        );
1148        // image_rgba reuses the text color pipeline (same vertex/bindings).
1149        let image_rgba = text_color.clone();
1150        // Fixed-function blend variants for `ImageBlended`, indexed like
1151        // `FIXED_FUNCTION_BLENDS`.
1152        let image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1153            std::array::from_fn(|index| {
1154                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1155                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1156                text_color_pipeline("image pipeline (blend)", blend)
1157            });
1158
1159        // Tinted A8 coverage composite. Same vertex attributes (GlyphInstance)
1160        // and bind groups (globals + texture/sampler) as the text color path,
1161        // sampling R8 tiles uploaded via `register_coverage_a8`.
1162        let coverage_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1163            label: Some("coverage.wgsl"),
1164            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/coverage.wgsl"))),
1165        });
1166        let coverage = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1167            label: Some("coverage pipeline (tinted a8)"),
1168            layout: Some(&text_pipeline_layout),
1169            vertex: wgpu::VertexState {
1170                module: &coverage_shader,
1171                entry_point: Some("vs_main"),
1172                buffers: &[Some(glyph_vertex.clone())],
1173                compilation_options: wgpu::PipelineCompilationOptions::default(),
1174            },
1175            fragment: Some(wgpu::FragmentState {
1176                module: &coverage_shader,
1177                entry_point: Some("fs_main"),
1178                targets: &[Some(wgpu::ColorTargetState {
1179                    format,
1180                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1181                    write_mask: wgpu::ColorWrites::ALL,
1182                })],
1183                compilation_options: wgpu::PipelineCompilationOptions::default(),
1184            }),
1185            primitive: wgpu::PrimitiveState::default(),
1186            depth_stencil: Some(stencil_for_content.clone()),
1187            multisample: msaa_state,
1188            multiview_mask: None,
1189            cache: None,
1190        });
1191
1192        let blur_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1193            label: Some("blur_separable.wgsl"),
1194            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1195                "shaders/blur_separable.wgsl"
1196            ))),
1197        });
1198        let blur_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1199            label: Some("blur pipeline layout"),
1200            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1201            immediate_size: 0,
1202        });
1203        let blur_layout = wgpu::VertexBufferLayout {
1204            array_stride: std::mem::size_of::<BlurInstance>() as u64,
1205            step_mode: wgpu::VertexStepMode::Instance,
1206            attributes: &[
1207                wgpu::VertexAttribute {
1208                    shader_location: 0,
1209                    offset: 0,
1210                    format: wgpu::VertexFormat::Float32x4,
1211                },
1212                wgpu::VertexAttribute {
1213                    shader_location: 1,
1214                    offset: 16,
1215                    format: wgpu::VertexFormat::Float32x4,
1216                },
1217                wgpu::VertexAttribute {
1218                    shader_location: 2,
1219                    offset: 32,
1220                    format: wgpu::VertexFormat::Float32x4,
1221                },
1222                wgpu::VertexAttribute {
1223                    shader_location: 3,
1224                    offset: 48,
1225                    format: wgpu::VertexFormat::Float32x2,
1226                },
1227                wgpu::VertexAttribute {
1228                    shader_location: 4,
1229                    offset: 56,
1230                    format: wgpu::VertexFormat::Float32x4,
1231                },
1232                wgpu::VertexAttribute {
1233                    shader_location: 5,
1234                    offset: 72,
1235                    format: wgpu::VertexFormat::Uint32,
1236                },
1237                wgpu::VertexAttribute {
1238                    shader_location: 6,
1239                    offset: 76,
1240                    format: wgpu::VertexFormat::Uint32,
1241                },
1242                wgpu::VertexAttribute {
1243                    shader_location: 7,
1244                    offset: 80,
1245                    format: wgpu::VertexFormat::Float32x4,
1246                },
1247            ],
1248        };
1249        let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1250            label: Some("blur pipeline"),
1251            layout: Some(&blur_pipeline_layout),
1252            vertex: wgpu::VertexState {
1253                module: &blur_shader,
1254                entry_point: Some("vs_main"),
1255                buffers: &[Some(blur_layout.clone())],
1256                compilation_options: wgpu::PipelineCompilationOptions::default(),
1257            },
1258            fragment: Some(wgpu::FragmentState {
1259                module: &blur_shader,
1260                entry_point: Some("fs_alpha"),
1261                targets: &[Some(wgpu::ColorTargetState {
1262                    format,
1263                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1264                    write_mask: wgpu::ColorWrites::ALL,
1265                })],
1266                compilation_options: wgpu::PipelineCompilationOptions::default(),
1267            }),
1268            primitive: wgpu::PrimitiveState::default(),
1269            depth_stencil: Some(stencil_for_content.clone()),
1270            multisample: msaa_state,
1271            multiview_mask: None,
1272            cache: None,
1273        });
1274
1275        let blur_content = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1276            label: Some("blur content pipeline"),
1277            layout: Some(&blur_pipeline_layout),
1278            vertex: wgpu::VertexState {
1279                module: &blur_shader,
1280                entry_point: Some("vs_main"),
1281                buffers: &[Some(blur_layout)],
1282                compilation_options: wgpu::PipelineCompilationOptions::default(),
1283            },
1284            fragment: Some(wgpu::FragmentState {
1285                module: &blur_shader,
1286                entry_point: Some("fs_color"),
1287                targets: &[Some(wgpu::ColorTargetState {
1288                    format,
1289                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1290                    write_mask: wgpu::ColorWrites::ALL,
1291                })],
1292                compilation_options: wgpu::PipelineCompilationOptions::default(),
1293            }),
1294            primitive: wgpu::PrimitiveState::default(),
1295            depth_stencil: Some(stencil_for_content.clone()),
1296            multisample: msaa_state,
1297            multiview_mask: None,
1298            cache: None,
1299        });
1300
1301        // NV12 Image Pipeline
1302        let image_nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1303            label: Some("image_nv12.wgsl"),
1304            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1305                "shaders/image_nv12.wgsl"
1306            ))),
1307        });
1308        let image_nv12_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1309            label: Some("image nv12 pipeline layout"),
1310            bind_group_layouts: &[Some(globals_layout), Some(image_bind_layout_nv12)],
1311            immediate_size: 0,
1312        });
1313        let nv12_vertex_layout = wgpu::VertexBufferLayout {
1314            array_stride: std::mem::size_of::<Nv12Instance>() as u64,
1315            step_mode: wgpu::VertexStepMode::Instance,
1316            attributes: &[
1317                wgpu::VertexAttribute {
1318                    shader_location: 0,
1319                    offset: 0,
1320                    format: wgpu::VertexFormat::Float32x4,
1321                },
1322                wgpu::VertexAttribute {
1323                    shader_location: 1,
1324                    offset: 16,
1325                    format: wgpu::VertexFormat::Float32x4,
1326                },
1327                wgpu::VertexAttribute {
1328                    shader_location: 2,
1329                    offset: 32,
1330                    format: wgpu::VertexFormat::Float32x4,
1331                },
1332                wgpu::VertexAttribute {
1333                    shader_location: 3,
1334                    offset: 48,
1335                    format: wgpu::VertexFormat::Float32,
1336                },
1337                wgpu::VertexAttribute {
1338                    shader_location: 4,
1339                    offset: 52,
1340                    format: wgpu::VertexFormat::Float32,
1341                },
1342                wgpu::VertexAttribute {
1343                    shader_location: 5,
1344                    offset: 56,
1345                    format: wgpu::VertexFormat::Float32x4,
1346                },
1347            ],
1348        };
1349        let nv12_pipeline =
1350            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1351                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1352                    label: Some(label),
1353                    layout: Some(&image_nv12_layout),
1354                    vertex: wgpu::VertexState {
1355                        module: &image_nv12_shader,
1356                        entry_point: Some("vs_main"),
1357                        buffers: &[Some(nv12_vertex_layout.clone())],
1358                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1359                    },
1360                    fragment: Some(wgpu::FragmentState {
1361                        module: &image_nv12_shader,
1362                        entry_point: Some("fs_main"),
1363                        targets: &[Some(wgpu::ColorTargetState {
1364                            format,
1365                            blend: Some(blend),
1366                            write_mask: wgpu::ColorWrites::ALL,
1367                        })],
1368                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1369                    }),
1370                    primitive: wgpu::PrimitiveState::default(),
1371                    depth_stencil: Some(stencil_for_content.clone()),
1372                    multisample: msaa_state,
1373                    multiview_mask: None,
1374                    cache: None,
1375                })
1376            };
1377        let image_nv12 = nv12_pipeline(
1378            "image nv12 pipeline",
1379            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1380        );
1381        let image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1382            std::array::from_fn(|index| {
1383                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1384                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1385                nv12_pipeline("image nv12 pipeline (blend)", blend)
1386            });
1387
1388        let clip_color_target = wgpu::ColorTargetState {
1389            format,
1390            blend: None,
1391            write_mask: wgpu::ColorWrites::empty(),
1392        };
1393
1394        // Clipping
1395        let clip_shader_bin = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1396            label: Some("clip_round_rect_bin.wgsl"),
1397            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1398                "shaders/clip_round_rect_bin.wgsl"
1399            ))),
1400        });
1401        let clip_bin = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1402            label: Some("clip pipeline (bin)"),
1403            layout: Some(clip_pipeline_layout),
1404            vertex: wgpu::VertexState {
1405                module: &clip_shader_bin,
1406                entry_point: Some("vs_main"),
1407                buffers: &[Some(clip_vertex_layout.clone())],
1408                compilation_options: wgpu::PipelineCompilationOptions::default(),
1409            },
1410            fragment: Some(wgpu::FragmentState {
1411                module: &clip_shader_bin,
1412                entry_point: Some("fs_main"),
1413                targets: &[Some(clip_color_target.clone())],
1414                compilation_options: wgpu::PipelineCompilationOptions::default(),
1415            }),
1416            primitive: wgpu::PrimitiveState::default(),
1417            depth_stencil: Some(stencil_for_clip_inc.clone()),
1418            multisample: wgpu::MultisampleState {
1419                count: sample_count,
1420                mask: !0,
1421                alpha_to_coverage_enabled: false,
1422            },
1423            multiview_mask: None,
1424            cache: None,
1425        });
1426        let clip_dec = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1427            label: Some("clip pipeline (dec)"),
1428            layout: Some(clip_pipeline_layout),
1429            vertex: wgpu::VertexState {
1430                module: &clip_shader_bin,
1431                entry_point: Some("vs_main"),
1432                buffers: &[Some(clip_vertex_layout.clone())],
1433                compilation_options: wgpu::PipelineCompilationOptions::default(),
1434            },
1435            fragment: Some(wgpu::FragmentState {
1436                module: &clip_shader_bin,
1437                entry_point: Some("fs_main"),
1438                targets: &[Some(clip_color_target.clone())],
1439                compilation_options: wgpu::PipelineCompilationOptions::default(),
1440            }),
1441            primitive: wgpu::PrimitiveState::default(),
1442            depth_stencil: Some(stencil_for_clip_dec.clone()),
1443            multisample: wgpu::MultisampleState {
1444                count: sample_count,
1445                mask: !0,
1446                alpha_to_coverage_enabled: false,
1447            },
1448            multiview_mask: None,
1449            cache: None,
1450        });
1451
1452        let slug = Some(slug::create_pipeline(
1453            device,
1454            format,
1455            sample_count,
1456            stencil_for_content,
1457        ));
1458
1459        // Tessellated vector mesh pipeline (host-supplied vertex/index data).
1460        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1461            label: Some("mesh.wgsl"),
1462            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/mesh.wgsl"))),
1463        });
1464        let mesh_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1465            label: Some("mesh pipeline layout"),
1466            bind_group_layouts: &[Some(globals_layout), Some(mesh_bind_layout)],
1467            immediate_size: 0,
1468        });
1469        let mesh_vertex_layout = wgpu::VertexBufferLayout {
1470            array_stride: std::mem::size_of::<MeshVertex>() as u64,
1471            step_mode: wgpu::VertexStepMode::Vertex,
1472            attributes: &[
1473                wgpu::VertexAttribute {
1474                    shader_location: 0,
1475                    offset: 0,
1476                    format: wgpu::VertexFormat::Float32x2,
1477                },
1478                wgpu::VertexAttribute {
1479                    shader_location: 1,
1480                    offset: 8,
1481                    format: wgpu::VertexFormat::Float32x4,
1482                },
1483                wgpu::VertexAttribute {
1484                    shader_location: 2,
1485                    offset: 24,
1486                    format: wgpu::VertexFormat::Float32x2,
1487                },
1488            ],
1489        };
1490        let make_mesh_pipeline =
1491            |label: &str, depth: &wgpu::DepthStencilState, color: &wgpu::ColorTargetState| {
1492                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1493                    label: Some(label),
1494                    layout: Some(&mesh_pipeline_layout),
1495                    vertex: wgpu::VertexState {
1496                        module: &mesh_shader,
1497                        entry_point: Some("vs_main"),
1498                        buffers: &[Some(mesh_vertex_layout.clone())],
1499                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1500                    },
1501                    fragment: Some(wgpu::FragmentState {
1502                        module: &mesh_shader,
1503                        entry_point: Some("fs_main"),
1504                        targets: &[Some(color.clone())],
1505                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1506                    }),
1507                    primitive: wgpu::PrimitiveState {
1508                        topology: wgpu::PrimitiveTopology::TriangleList,
1509                        ..Default::default()
1510                    },
1511                    depth_stencil: Some(depth.clone()),
1512                    multisample: msaa_state,
1513                    multiview_mask: None,
1514                    cache: None,
1515                })
1516            };
1517        let mesh_color_target = wgpu::ColorTargetState {
1518            format,
1519            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1520            write_mask: wgpu::ColorWrites::ALL,
1521        };
1522        let mut stencil_for_mesh = stencil_for_content.clone();
1523        stencil_for_mesh.stencil.front.compare = wgpu::CompareFunction::Equal;
1524        stencil_for_mesh.stencil.back.compare = wgpu::CompareFunction::Equal;
1525        let mut stencil_for_overlay = stencil_for_content.clone();
1526        stencil_for_overlay.stencil.front.compare = wgpu::CompareFunction::LessEqual;
1527        stencil_for_overlay.stencil.back.compare = wgpu::CompareFunction::LessEqual;
1528        let mesh = make_mesh_pipeline("mesh pipeline", &stencil_for_mesh, &mesh_color_target);
1529        let mesh_overlay = make_mesh_pipeline(
1530            "mesh overlay pipeline",
1531            &stencil_for_overlay,
1532            &mesh_color_target,
1533        );
1534        let mesh_clip_inc = make_mesh_pipeline(
1535            "mesh clip (inc) pipeline",
1536            stencil_for_clip_inc,
1537            &clip_color_target,
1538        );
1539        let mesh_clip_dec = make_mesh_pipeline(
1540            "mesh clip (dec) pipeline",
1541            stencil_for_clip_dec,
1542            &clip_color_target,
1543        );
1544        let mesh_blend_target = |blend: wgpu::BlendState| wgpu::ColorTargetState {
1545            format,
1546            blend: Some(blend),
1547            write_mask: wgpu::ColorWrites::ALL,
1548        };
1549        let mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1550            std::array::from_fn(|index| {
1551                let mode = FIXED_FUNCTION_BLENDS[index];
1552                let blend = fixed_function_blend(mode).unwrap_or(wgpu::BlendState::REPLACE);
1553                make_mesh_pipeline(
1554                    match mode {
1555                        repose_core::BlendMode::Add => "mesh pipeline (add)",
1556                        repose_core::BlendMode::Multiply => "mesh pipeline (multiply)",
1557                        repose_core::BlendMode::Screen => "mesh pipeline (screen)",
1558                        repose_core::BlendMode::Darken => "mesh pipeline (darken)",
1559                        _ => "mesh pipeline (lighten)",
1560                    },
1561                    &stencil_for_mesh,
1562                    &mesh_blend_target(blend),
1563                )
1564            });
1565        // Projective layer composite (perspective flattening). Same
1566        // bind groups as the text/image path (globals + layer texture), with
1567        // per-instance projected corners. Like `image_rgba` it draws into the
1568        // parent target, so it shares the content stencil state.
1569        let projective_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1570            label: Some("projective_layer.wgsl"),
1571            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1572                "shaders/projective_layer.wgsl"
1573            ))),
1574        });
1575        let projective_pipeline_layout =
1576            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1577                label: Some("projective layer pipeline layout"),
1578                bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1579                immediate_size: 0,
1580            });
1581        let projective_vertex_layout = wgpu::VertexBufferLayout {
1582            array_stride: std::mem::size_of::<ProjectiveInstance>() as u64,
1583            step_mode: wgpu::VertexStepMode::Instance,
1584            attributes: &[
1585                wgpu::VertexAttribute {
1586                    shader_location: 0,
1587                    offset: 0,
1588                    format: wgpu::VertexFormat::Float32x2,
1589                },
1590                wgpu::VertexAttribute {
1591                    shader_location: 1,
1592                    offset: 8,
1593                    format: wgpu::VertexFormat::Float32x2,
1594                },
1595                wgpu::VertexAttribute {
1596                    shader_location: 2,
1597                    offset: 16,
1598                    format: wgpu::VertexFormat::Float32x2,
1599                },
1600                wgpu::VertexAttribute {
1601                    shader_location: 3,
1602                    offset: 24,
1603                    format: wgpu::VertexFormat::Float32x2,
1604                },
1605                wgpu::VertexAttribute {
1606                    shader_location: 4,
1607                    offset: 32,
1608                    format: wgpu::VertexFormat::Float32x4,
1609                },
1610                wgpu::VertexAttribute {
1611                    shader_location: 5,
1612                    offset: 48,
1613                    format: wgpu::VertexFormat::Float32x4,
1614                },
1615                wgpu::VertexAttribute {
1616                    shader_location: 6,
1617                    offset: 64,
1618                    format: wgpu::VertexFormat::Float32,
1619                },
1620            ],
1621        };
1622        let projective_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1623            label: Some("projective layer composite pipeline"),
1624            layout: Some(&projective_pipeline_layout),
1625            vertex: wgpu::VertexState {
1626                module: &projective_shader,
1627                entry_point: Some("vs_main"),
1628                buffers: &[Some(projective_vertex_layout)],
1629                compilation_options: wgpu::PipelineCompilationOptions::default(),
1630            },
1631            fragment: Some(wgpu::FragmentState {
1632                module: &projective_shader,
1633                entry_point: Some("fs_main"),
1634                targets: &[Some(wgpu::ColorTargetState {
1635                    format,
1636                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1637                    write_mask: wgpu::ColorWrites::ALL,
1638                })],
1639                compilation_options: wgpu::PipelineCompilationOptions::default(),
1640            }),
1641            primitive: wgpu::PrimitiveState::default(),
1642            depth_stencil: Some(stencil_for_content.clone()),
1643            multisample: msaa_state,
1644            multiview_mask: None,
1645            cache: None,
1646        });
1647
1648        let blend_layer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1649            label: Some("blend_layer.wgsl"),
1650            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1651                "shaders/blend_layer.wgsl"
1652            ))),
1653        });
1654        let blend_layer_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1655            label: Some("blend layer pipeline layout"),
1656            bind_group_layouts: &[
1657                Some(globals_layout),
1658                Some(text_bind_layout),
1659                Some(text_bind_layout),
1660            ],
1661            immediate_size: 0,
1662        });
1663        let blend_vertex_layout = wgpu::VertexBufferLayout {
1664            array_stride: std::mem::size_of::<BlendInstance>() as u64,
1665            step_mode: wgpu::VertexStepMode::Instance,
1666            attributes: &[
1667                wgpu::VertexAttribute {
1668                    shader_location: 0,
1669                    offset: 0,
1670                    format: wgpu::VertexFormat::Float32x4,
1671                },
1672                wgpu::VertexAttribute {
1673                    shader_location: 1,
1674                    offset: 16,
1675                    format: wgpu::VertexFormat::Float32x4,
1676                },
1677                wgpu::VertexAttribute {
1678                    shader_location: 2,
1679                    offset: 32,
1680                    format: wgpu::VertexFormat::Float32x4,
1681                },
1682                wgpu::VertexAttribute {
1683                    shader_location: 3,
1684                    offset: 48,
1685                    format: wgpu::VertexFormat::Float32x4,
1686                },
1687                wgpu::VertexAttribute {
1688                    shader_location: 4,
1689                    offset: 64,
1690                    format: wgpu::VertexFormat::Uint32,
1691                },
1692            ],
1693        };
1694        let blend_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1695            label: Some("blend layer pipeline"),
1696            layout: Some(&blend_layer_layout),
1697            vertex: wgpu::VertexState {
1698                module: &blend_layer_shader,
1699                entry_point: Some("vs_main"),
1700                buffers: &[Some(blend_vertex_layout)],
1701                compilation_options: wgpu::PipelineCompilationOptions::default(),
1702            },
1703            fragment: Some(wgpu::FragmentState {
1704                module: &blend_layer_shader,
1705                entry_point: Some("fs_main"),
1706                targets: &[Some(wgpu::ColorTargetState {
1707                    format,
1708                    blend: None,
1709                    write_mask: wgpu::ColorWrites::ALL,
1710                })],
1711                compilation_options: wgpu::PipelineCompilationOptions::default(),
1712            }),
1713            primitive: wgpu::PrimitiveState::default(),
1714            depth_stencil: Some(stencil_for_content.clone()),
1715            multisample: msaa_state,
1716            multiview_mask: None,
1717            cache: None,
1718        });
1719
1720        Self {
1721            rects,
1722            borders,
1723            ellipses,
1724            ellipse_borders,
1725            arcs,
1726            text_mask,
1727            text_color,
1728            image_rgba,
1729            image_blends,
1730            image_nv12,
1731            image_nv12_blends,
1732            coverage,
1733            blur,
1734            blur_content,
1735            clip_bin,
1736            clip_dec,
1737            slug,
1738            mesh,
1739            mesh_blends,
1740            blend_layer,
1741            mesh_overlay,
1742            mesh_clip_inc,
1743            mesh_clip_dec,
1744            projective_layer,
1745        }
1746    }
1747}
1748
1749/// AABB of a rect under the *plain affine* part of a transform (linear +
1750/// translation, no origin re-pivot).
1751fn affine_aabb(transform: &Transform, rect: &repose_core::Rect) -> repose_core::Rect {
1752    let m = transform.linear();
1753    let (tx, ty) = (transform.translate_x, transform.translate_y);
1754    let corners = [
1755        (rect.x, rect.y),
1756        (rect.x + rect.w, rect.y),
1757        (rect.x, rect.y + rect.h),
1758        (rect.x + rect.w, rect.y + rect.h),
1759    ];
1760    let mut min_x = f32::INFINITY;
1761    let mut min_y = f32::INFINITY;
1762    let mut max_x = f32::NEG_INFINITY;
1763    let mut max_y = f32::NEG_INFINITY;
1764    for (x, y) in corners {
1765        let wx = m[0] * x + m[1] * y + tx;
1766        let wy = m[2] * x + m[3] * y + ty;
1767        min_x = min_x.min(wx);
1768        min_y = min_y.min(wy);
1769        max_x = max_x.max(wx);
1770        max_y = max_y.max(wy);
1771    }
1772    repose_core::Rect {
1773        x: min_x,
1774        y: min_y,
1775        w: (max_x - min_x).max(0.0),
1776        h: (max_y - min_y).max(0.0),
1777    }
1778}
1779
1780/// Convert a local-space rect + transform to NDC center-based position+size
1781/// plus the forward rotation/shear 2x2 (row-major `[m00, m01, m10, m11]`,
1782/// scale-free: scale rides in the NDC size). Shaders apply it to quad corners
1783/// and its adjugate/determinant inverse to sample positions.
1784fn rect_to_instance_ndc(
1785    rect: repose_core::Rect,
1786    transform: &Transform,
1787    fb_w: f32,
1788    fb_h: f32,
1789) -> ([f32; 4], [f32; 4]) {
1790    let cx = rect.x + rect.w * 0.5;
1791    let cy = rect.y + rect.h * 0.5;
1792
1793    let m = transform.linear();
1794    let tx = m[0] * cx + m[1] * cy + transform.translate_x;
1795    let ty = m[2] * cx + m[3] * cy + transform.translate_y;
1796
1797    let ndc_cx = (tx / fb_w) * 2.0 - 1.0;
1798    let ndc_cy = 1.0 - (ty / fb_h) * 2.0;
1799    // NDC size (after scale only, no rotation - rotation is done in shader)
1800    let ndc_w = (rect.w * transform.scale_x / fb_w) * 2.0;
1801    let ndc_h = (rect.h * transform.scale_y / fb_h) * 2.0;
1802
1803    ([ndc_cx, ndc_cy, ndc_w, ndc_h], forward_rs_mat(transform))
1804}
1805
1806/// Forward rotation+shear 2x2 (row-major, scale-free) for instance
1807/// attributes. Identity for untransformed content; degenerate shear (only
1808/// from absurd inputs) falls back to identity.
1809fn forward_rs_mat(transform: &Transform) -> [f32; 4] {
1810    let c = transform.rotate.cos();
1811    let s = transform.rotate.sin();
1812    let (hx, hy) = (transform.shear_x, transform.shear_y);
1813    let m = [c - s * hy, c * hx - s, s + c * hy, s * hx + c];
1814    if (m[0] * m[3] - m[1] * m[2]).abs() < 1e-6 {
1815        return [1.0, 0.0, 0.0, 1.0];
1816    }
1817    m
1818}
1819
1820/// A segment of the frame that draws into a single render target.
1821struct Pass {
1822    id: u64,
1823    target: PassTarget,
1824    /// The scissor to apply when the pass is opened.
1825    initial_scissor: (u32, u32, u32, u32),
1826    /// The currently active scissor in the translated pass. `None` means an
1827    /// empty intersection and all draws must be suppressed.
1828    active_scissor: Option<(u32, u32, u32, u32)>,
1829    /// `None` means `LoadOp::Load` (resume existing content);
1830    /// `Some(c)` means `LoadOp::Clear(c)`.
1831    clear_color: Option<[f32; 4]>,
1832    cmds: Vec<Cmd>,
1833}
1834
1835struct LayerState {
1836    layer_id: u32,
1837    parent_target: PassTarget,
1838    parent_scissors: Vec<repose_core::Rect>,
1839    parent_root_clip: repose_core::Rect,
1840    parent_size: (f32, f32),
1841    parent_transform: Transform,
1842    parent_scissor: Option<(u32, u32, u32, u32)>,
1843    parent_clips: Vec<ActiveClip>,
1844    alpha: f32,
1845    blur: (f32, f32),
1846    rectangle_edge: bool,
1847}
1848
1849/// One translator-flattened perspective layer (see
1850/// `push_perspective_layer`): the projective map and everything needed to
1851/// restore the parent target on the matching pop.
1852struct FlattenRecord {
1853    /// `transform_stack.len()` before this flatten pushed its two entries
1854    /// (stripped affine + layer-local shift).
1855    stack_len: usize,
1856    layer_id: u32,
1857    /// Full projective map (row-major 3x3): affine ancestors over the
1858    /// perspective node, in parent-target coordinates.
1859    map: [f32; 9],
1860    /// Layer rect in the parent target's coordinates.
1861    layer_rect: repose_core::Rect,
1862    saved_scissor_stack: Vec<repose_core::Rect>,
1863    saved_root: repose_core::Rect,
1864    saved_size: (f32, f32),
1865    saved_active_scissor: Option<(u32, u32, u32, u32)>,
1866    saved_clips: Vec<ActiveClip>,
1867}
1868
1869/// First translator-owned flatten layer id. Producer ids start at 1 per
1870/// scene, so this range never collides; ids are drained from the layer pool
1871/// after each frame, so reuse across frames is safe.
1872const FLATTEN_ID_BASE: u32 = 0xF000_0000;
1873const MAX_GRAPHICS_LAYERS: usize = 128;
1874const MAX_GRAPHICS_LAYER_BYTES: u64 = 256 * 1024 * 1024;
1875const MAX_TRANSIENT_GRAPHICS_LAYERS: usize = 64;
1876const MAX_TRANSIENT_GRAPHICS_LAYER_BYTES: u64 = 128 * 1024 * 1024;
1877const STENCIL_BASE: u32 = 128;
1878const STENCIL_MAX_DEPTH: u32 = 127;
1879
1880#[allow(non_snake_case)]
1881enum Cmd {
1882    ClipPush {
1883        off: u64,
1884        cnt: u32,
1885        scissor: (u32, u32, u32, u32),
1886        difference: bool,
1887        applied: bool,
1888    },
1889    ClipPop {
1890        off: u64,
1891        cnt: u32,
1892        /// Scissor restored for the draws that follow the pop (the parent clip).
1893        scissor: (u32, u32, u32, u32),
1894        /// Scissor the matching push drew its stencil pass with; the decrement
1895        /// must use it so both passes touch the same pixels.
1896        stencil_scissor: (u32, u32, u32, u32),
1897        difference: bool,
1898        applied: bool,
1899    },
1900    Rect {
1901        off: u64,
1902        cnt: u32,
1903    },
1904    Border {
1905        off: u64,
1906        cnt: u32,
1907    },
1908    Ellipse {
1909        off: u64,
1910        cnt: u32,
1911    },
1912    EllipseBorder {
1913        off: u64,
1914        cnt: u32,
1915    },
1916    Arc {
1917        off: u64,
1918        cnt: u32,
1919    },
1920    GlyphsMask {
1921        off: u64,
1922        cnt: u32,
1923    },
1924    GlyphsColor {
1925        off: u64,
1926        cnt: u32,
1927    },
1928    GlyphsVector {
1929        off: u64,
1930        cnt: u32,
1931    },
1932    ImageRgba {
1933        off: u64,
1934        cnt: u32,
1935        handle: u64,
1936        filter: ImageFilter,
1937        /// Bind to the repeating samplers so `ImageFit::Tile` UVs wrap.
1938        tile: bool,
1939        /// Fixed-function blend mode; `Alpha` uses `image_rgba`.
1940        blend: repose_core::BlendMode,
1941    },
1942    /// Composite a tinted A8 coverage tile (`SceneNode::Coverage`). The
1943    /// instance lives in `self.glyph_color.ring` (a `GlyphInstance`); the
1944    /// bind comes from the coverage registry.
1945    Coverage {
1946        off: u64,
1947        cnt: u32,
1948        handle: u64,
1949    },
1950    ImageNv12 {
1951        off: u64,
1952        cnt: u32,
1953        handle: u64,
1954        filter: ImageFilter,
1955        /// Fixed-function blend mode; `Alpha` uses `image_nv12`.
1956        blend: repose_core::BlendMode,
1957    },
1958    /// Composite a previously-rendered graphics layer back into the
1959    /// current target as a textured quad. The quad's vertex buffer
1960    /// lives in `self.glyph_color.ring` (a `GlyphInstance`).
1961    CompositeLayer {
1962        off: u64,
1963        cnt: u32,
1964        layer_id: u32,
1965    },
1966    /// Separable gaussian blur, alpha channel only: the horizontal pass into a
1967    /// scratch layer, and the vertical pass that tints the result as a drop
1968    /// shadow. `layer_id` names the texture to sample, which is the source
1969    /// layer for the first pass and the scratch for the second. The quad's
1970    /// vertex buffer lives in `self.blur_ring` (a `BlurInstance`).
1971    CompositeShadow {
1972        off: u64,
1973        cnt: u32,
1974        layer_id: u32,
1975    },
1976    /// Separable gaussian blur over full RGBA, for `Modifier::blur`. Two
1977    /// stages exactly like [`Cmd::CompositeShadow`].
1978    CompositeBlur {
1979        off: u64,
1980        cnt: u32,
1981        layer_id: u32,
1982    },
1983    /// Composite a flattened perspective layer through its projective map.
1984    /// The instance lives in `self.projective_ring` (a `ProjectiveInstance`
1985    /// with CPU-projected NDC corners); sampled from the layer's texture
1986    /// with perspective-correct UVs by the `projective_layer` pipeline.
1987    CompositeProjective {
1988        off: u64,
1989        cnt: u32,
1990        layer_id: u32,
1991    },
1992    /// Draw a tessellated vector mesh (solid or gradient paint).
1993    VectorMesh {
1994        mesh: MeshGpu,
1995        uoff: u64,
1996        blend: repose_core::BlendMode,
1997    },
1998    /// Composite an isolated source layer over the current target with a
1999    /// backdrop-dependent CSS blend mode. The shader samples both textures
2000    /// and composites source-over by hand, so the pass runs REPLACE.
2001    /// `parent` records which target the composite draws into (surface or
2002    /// layer); the instance NDC is mapped against it, so any parent origin
2003    /// works.
2004    BlendLayer {
2005        off: u64,
2006        cnt: u32,
2007        src_layer: u32,
2008        dst_layer: Option<u32>,
2009        parent: PassTarget,
2010        scissor: (u32, u32, u32, u32),
2011    },
2012    /// Draw a screen-space overlay mesh (identity transform, device pixels).
2013    VectorOverlay {
2014        mesh: MeshGpu,
2015        uoff: u64,
2016    },
2017    /// Increment the stencil buffer with a tessellated vector mask.
2018    /// `difference` marks an inverse (`\iclip`-style) mask: content draws
2019    /// *outside* it. The counting still balances (push increments, pop
2020    /// decrements); only the depth bookkeeping differs (see executor).
2021    VectorClipPush {
2022        mesh: MeshGpu,
2023        uoff: u64,
2024        scissor: (u32, u32, u32, u32),
2025        difference: bool,
2026        applied: bool,
2027    },
2028    /// Decrement the stencil buffer with the matching vector mask.
2029    VectorClipPop {
2030        mesh: MeshGpu,
2031        uoff: u64,
2032        scissor: (u32, u32, u32, u32),
2033        stencil_scissor: (u32, u32, u32, u32),
2034        difference: bool,
2035        applied: bool,
2036    },
2037    Callback {
2038        rect: repose_core::Rect,
2039        clip_rect: repose_core::Rect,
2040        scissor: (u32, u32, u32, u32),
2041        restore_scissor: (u32, u32, u32, u32),
2042        callback_id: usize,
2043        payload: repose_core::PaintCallbackPayload,
2044    },
2045}
2046
2047/// A registered A8 coverage tile: single-channel mask sampled as coverage
2048/// by `SceneNode::Coverage`. Tiles are immutable; producers re-register on
2049/// geometry change and `remove_coverage` stale handles (unused tiles also
2050/// age out via the image eviction policy).
2051struct CoverageTex {
2052    // Held to keep the GPU texture alive (freed on remove/evict).
2053    #[allow(dead_code)]
2054    tex: wgpu::Texture,
2055    bind: wgpu::BindGroup,
2056    w: u32,
2057    h: u32,
2058    last_used_frame: u64,
2059    bytes: u64,
2060}
2061
2062struct ImageBinds {
2063    linear: wgpu::BindGroup,
2064    nearest: wgpu::BindGroup,
2065    /// `ImageFit::Tile` variants, bound to repeating samplers.
2066    tile_linear: wgpu::BindGroup,
2067    tile_nearest: wgpu::BindGroup,
2068}
2069
2070impl ImageBinds {
2071    fn get(&self, filter: ImageFilter, tile: bool) -> &wgpu::BindGroup {
2072        match (filter, tile) {
2073            (ImageFilter::Linear, false) => &self.linear,
2074            (ImageFilter::Nearest, false) => &self.nearest,
2075            (ImageFilter::Linear, true) => &self.tile_linear,
2076            (ImageFilter::Nearest, true) => &self.tile_nearest,
2077        }
2078    }
2079}
2080
2081enum ImageTex {
2082    Rgba {
2083        tex: wgpu::Texture,
2084        binds: ImageBinds,
2085        w: u32,
2086        h: u32,
2087        format: wgpu::TextureFormat,
2088        last_used_frame: u64,
2089        bytes: u64,
2090    },
2091    /// For a user-provided texture view.
2092    User {
2093        binds: ImageBinds,
2094        w: u32,
2095        h: u32,
2096        last_used_frame: u64,
2097        bytes: u64,
2098    },
2099    Nv12 {
2100        tex_y: wgpu::Texture,
2101        tex_uv: wgpu::Texture,
2102        binds: ImageBinds,
2103        yuv_buf: wgpu::Buffer,
2104        w: u32,
2105        h: u32,
2106        pixel_format: PixelFormat,
2107        fourcc: Option<u32>,
2108        color_info: ColorInfo,
2109        external: bool,
2110        last_used_frame: u64,
2111        bytes: u64,
2112    },
2113}
2114
2115impl ImageTex {
2116    fn evictable(&self) -> bool {
2117        match self {
2118            Self::Rgba { .. } => true,
2119            Self::User { .. } => false,
2120            Self::Nv12 { external, .. } => !*external,
2121        }
2122    }
2123}
2124
2125#[derive(Clone)]
2126struct RetainedImage {
2127    w: u32,
2128    h: u32,
2129    format: wgpu::TextureFormat,
2130    rgba: Vec<u8>,
2131    last_used_frame: u64,
2132}
2133
2134struct ResolvedImageSource {
2135    width: f32,
2136    height: f32,
2137    uv_min: [f32; 2],
2138    uv_max: [f32; 2],
2139}
2140
2141fn resolve_image_source(
2142    source_rect: Option<ImageSourceRect>,
2143    image_width: u32,
2144    image_height: u32,
2145) -> Option<ResolvedImageSource> {
2146    if image_width == 0 || image_height == 0 {
2147        return None;
2148    }
2149    let Some(source) = source_rect else {
2150        return Some(ResolvedImageSource {
2151            width: image_width as f32,
2152            height: image_height as f32,
2153            uv_min: [0.0, 0.0],
2154            uv_max: [1.0, 1.0],
2155        });
2156    };
2157    if source.is_empty() {
2158        return None;
2159    }
2160    let right = source.x.checked_add(source.width)?;
2161    let bottom = source.y.checked_add(source.height)?;
2162    if right > image_width || bottom > image_height {
2163        return None;
2164    }
2165    Some(ResolvedImageSource {
2166        width: source.width as f32,
2167        height: source.height as f32,
2168        uv_min: [
2169            source.x as f32 / image_width as f32,
2170            source.y as f32 / image_height as f32,
2171        ],
2172        uv_max: [
2173            right as f32 / image_width as f32,
2174            bottom as f32 / image_height as f32,
2175        ],
2176    })
2177}
2178
2179/// Draw rect and UV range for `fit` inside `rect`.
2180///
2181/// Mirrors Compose's `PainterNode.draw`: scale the source by
2182/// `ContentScale.computeScaleFactor`, align the result within the bounds, then
2183/// clip to those bounds. Clipping remaps the UVs rather than pushing a clip
2184/// rect, matching what Compose's `clipToBounds` and Godot's `clip_contents`
2185/// produce visually without the extra pass. Modes that scale past an edge
2186/// (`Cover`, `FitWidth`, `FitHeight`, `None`) rely on it to stay in bounds.
2187fn image_fit_geometry(
2188    rect: repose_core::Rect,
2189    fit: ImageFit,
2190    alignment: ImageAlignment,
2191    source: &ResolvedImageSource,
2192) -> Option<(repose_core::Rect, [f32; 4])> {
2193    let src_w = source.width;
2194    let src_h = source.height;
2195    let dst_w = rect.w.max(0.0);
2196    let dst_h = rect.h.max(0.0);
2197    if src_w <= 0.0 || src_h <= 0.0 || dst_w <= 0.0 || dst_h <= 0.0 {
2198        return None;
2199    }
2200
2201    // Slack placement inside the destination rect: `alignment` picks which
2202    // corner the fitted image hugs (CSS `object-position`).
2203    let slack = |content: f32, extent: f32| match alignment {
2204        ImageAlignment::Center => (extent - content) * 0.5,
2205        ImageAlignment::Begin => 0.0,
2206        ImageAlignment::End => extent - content,
2207    };
2208
2209    // A tiled draw covers the bounds at native resolution, so its UVs run past
2210    // 1.0 and the sampler wraps (see address-mode selection at draw time).
2211    let tiled = matches!(fit, ImageFit::Tile);
2212    let (scale_x, scale_y) = fit.scale_factor((src_w, src_h), (dst_w, dst_h));
2213    let drawn = if tiled {
2214        repose_core::Rect {
2215            x: rect.x,
2216            y: rect.y,
2217            w: dst_w,
2218            h: dst_h,
2219        }
2220    } else {
2221        let (w, h) = (src_w * scale_x, src_h * scale_y);
2222        repose_core::Rect {
2223            x: rect.x + slack(w, dst_w),
2224            y: rect.y + slack(h, dst_h),
2225            w,
2226            h,
2227        }
2228    };
2229
2230    let uv_min = source.uv_min;
2231    let uv_max = source.uv_max;
2232    let repeats = if tiled {
2233        [dst_w / src_w, dst_h / src_h]
2234    } else {
2235        [1.0, 1.0]
2236    };
2237    let mut uv = [
2238        uv_min[0],
2239        uv_max[1],
2240        uv_min[0] + (uv_max[0] - uv_min[0]) * repeats[0],
2241        uv_max[1] - (uv_max[1] - uv_min[1]) * repeats[1],
2242    ];
2243
2244    let clipped = intersect(drawn, rect);
2245    if clipped.w <= 0.0 || clipped.h <= 0.0 {
2246        return None;
2247    }
2248    let du = uv[2] - uv[0];
2249    let dv = uv[3] - uv[1];
2250    uv = [
2251        uv[0] + du * (clipped.x - drawn.x) / drawn.w,
2252        uv[1] + dv * (clipped.y - drawn.y) / drawn.h,
2253        uv[0] + du * (clipped.x + clipped.w - drawn.x) / drawn.w,
2254        uv[1] + dv * (clipped.y + clipped.h - drawn.y) / drawn.h,
2255    ];
2256    Some((clipped, uv))
2257}
2258
2259struct AtlasA8 {
2260    tex: wgpu::Texture,
2261    view: wgpu::TextureView,
2262    sampler: wgpu::Sampler,
2263    size: u32,
2264    next_x: u32,
2265    next_y: u32,
2266    row_h: u32,
2267    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2268    /// Summed `w * h` of every live entry. The pixel budget is what actually
2269    /// binds a shelf packer, so eviction trims on area, not entry count.
2270    used_area: u64,
2271}
2272
2273struct AtlasRGBA {
2274    tex: wgpu::Texture,
2275    view: wgpu::TextureView,
2276    sampler: wgpu::Sampler,
2277    size: u32,
2278    next_x: u32,
2279    next_y: u32,
2280    row_h: u32,
2281    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2282    used_area: u64,
2283}
2284
2285/// Work an atlas could not do yet, applied at the next frame boundary. Both
2286/// growth and compaction re-pack the atlas, which would invalidate UVs already
2287/// batched into the frame in flight, so neither may run mid-frame.
2288#[derive(Default, Clone, Copy)]
2289struct AtlasPending {
2290    /// A cell did not fit and the atlas is still below its size cap.
2291    grow: bool,
2292    /// The atlas is at its cap, so the only remedy left is dropping cold
2293    /// entries.
2294    compact: bool,
2295}
2296
2297#[derive(Clone, Copy)]
2298struct GlyphInfo {
2299    u0: f32,
2300    v0: f32,
2301    u1: f32,
2302    v1: f32,
2303    w: f32,
2304    h: f32,
2305    /// `cache_touch_clock` at the last request, and the frame this glyph was
2306    /// last batched into. Entries in flight are never evicted: the instance
2307    /// buffer already holds their UVs, and a repack would move them.
2308    last_touch: u64,
2309    last_used_frame: u64,
2310}
2311
2312#[derive(Clone, Hash, PartialEq, Eq)]
2313struct TextShapeKey {
2314    text: Arc<str>,
2315    px_bits: u32,
2316    line_height_bits: u32,
2317    font_family: Option<&'static str>,
2318    font_weight: u16,
2319    font_style: u8,
2320    letter_spacing_bits: u32,
2321    variation: Option<Arc<str>>,
2322    font_generation: u64,
2323}
2324
2325struct TextShapeCacheEntry {
2326    glyphs: Arc<[repose_text::ShapedGlyph]>,
2327    last_used_frame: u64,
2328}
2329
2330/// Instance data for one shape. The brush enums share `bits` rather than
2331/// taking a varying each, and `grad_p0`/`grad_p1` merge into one `grad`
2332/// vector, to keep the fragment stage inside the varying budget that
2333/// `field_offset!` in `Pipelines::create` derives the attribute table from.
2334#[repr(C)]
2335#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2336struct RectInstance {
2337    xywh: [f32; 4],
2338    radii: [f32; 4],
2339    color0: [f32; 4],
2340    color1: [f32; 4],
2341    fwd_mat: [f32; 4],
2342    grad: [f32; 4],
2343    bits: u32,
2344    _pad: [f32; 3],
2345}
2346
2347#[repr(C)]
2348#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2349struct BorderInstance {
2350    xywh: [f32; 4],
2351    radii: [f32; 4],
2352    color0: [f32; 4],
2353    color1: [f32; 4],
2354    fwd_mat: [f32; 4],
2355    grad: [f32; 4],
2356    bits: u32,
2357    stroke: f32,
2358    _pad: [f32; 2],
2359}
2360
2361#[repr(C)]
2362#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2363struct EllipseInstance {
2364    xywh: [f32; 4],
2365    color0: [f32; 4],
2366    color1: [f32; 4],
2367    fwd_mat: [f32; 4],
2368    grad: [f32; 4],
2369    bits: u32,
2370    _pad: [f32; 1],
2371}
2372
2373#[repr(C)]
2374#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2375struct EllipseBorderInstance {
2376    xywh: [f32; 4],
2377    color0: [f32; 4],
2378    color1: [f32; 4],
2379    fwd_mat: [f32; 4],
2380    grad: [f32; 4],
2381    bits: u32,
2382    stroke: f32,
2383    pad: f32,
2384    _pad: f32,
2385}
2386
2387#[repr(C)]
2388#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2389struct ArcInstance {
2390    xywh: [f32; 4],
2391    color0: [f32; 4],
2392    color1: [f32; 4],
2393    fwd_mat: [f32; 4],
2394    grad: [f32; 4],
2395    bits: u32,
2396    start_angle: f32,
2397    sweep_angle: f32,
2398    stroke: f32,
2399    pad: f32,
2400    _pad: f32,
2401}
2402
2403#[repr(C)]
2404#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2405struct GlyphInstance {
2406    xywh: [f32; 4],
2407    uv: [f32; 4],
2408    color: [f32; 4],
2409    fwd_mat: [f32; 4],
2410}
2411
2412#[repr(C)]
2413#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2414struct BlurInstance {
2415    xywh: [f32; 4],
2416    uv: [f32; 4],
2417    color: [f32; 4],
2418    sigma_px: [f32; 2],
2419    fwd_mat: [f32; 4],
2420    axis: u32,
2421    edge_mode: u32,
2422    /// Corner radii, in source texels, shaping the blurred silhouette. All zero
2423    /// leaves it unshaped. Read only by the source-reading axis; the axis that
2424    /// samples the scratch must leave this zero.
2425    shape: [f32; 4],
2426}
2427
2428/// Projective layer-composite instance: the four layer-rect corners projected
2429/// to NDC (`c0..c3`, counter-clockwise from top-left) with their homogeneous
2430/// `w`, the layer-texture uv bounds, and a group alpha. Matches
2431/// `projective_layer.wgsl` (offsets: c0@0 c1@8 c2@16 c3@24 uv@32 w@48
2432/// alpha@64; stride 80).
2433#[repr(C)]
2434#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2435struct ProjectiveInstance {
2436    c0: [f32; 2],
2437    c1: [f32; 2],
2438    c2: [f32; 2],
2439    c3: [f32; 2],
2440    uv: [f32; 4],
2441    w: [f32; 4],
2442    alpha: f32,
2443    _pad: [f32; 3],
2444}
2445
2446/// CPU-computed Y′CbCr -> R′G′B′ transform uploaded as a uniform buffer.
2447/// Layout matches the WGSL `YuvTransform` struct (5 × vec4<f32>).
2448#[repr(C)]
2449#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2450struct YuvTransformRaw {
2451    row0: [f32; 4],
2452    row1: [f32; 4],
2453    row2: [f32; 4],
2454    b: [f32; 4],
2455    transfer: [f32; 4],
2456}
2457
2458fn make_yuv_transform_raw(color_info: ColorInfo, pixel_format: PixelFormat) -> YuvTransformRaw {
2459    let yuv = color_info.to_yuv_transform();
2460    let sample_scale = match pixel_format {
2461        PixelFormat::P010 => 65535.0 / (64.0 * 1020.0),
2462        _ => 1.0,
2463    };
2464    let transfer_mode = match color_info.transfer {
2465        repose_core::color::Transfer::Srgb => 0.0,
2466        repose_core::color::Transfer::Bt709 => 1.0,
2467        repose_core::color::Transfer::Linear => 2.0,
2468        repose_core::color::Transfer::Pq => 3.0,
2469        repose_core::color::Transfer::Hlg => 4.0,
2470    };
2471    YuvTransformRaw {
2472        row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2473        row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2474        row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2475        b: [yuv.b[0], yuv.b[1], yuv.b[2], sample_scale],
2476        transfer: [transfer_mode, 0.0, 0.0, 0.0],
2477    }
2478}
2479
2480#[repr(C)]
2481#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2482struct Nv12Instance {
2483    xywh: [f32; 4],
2484    uv: [f32; 4],
2485    color: [f32; 4],
2486    uv_x_offset: f32,
2487    uv_y_offset: f32,
2488    fwd_mat: [f32; 4],
2489}
2490
2491#[repr(C)]
2492#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2493struct ClipInstance {
2494    xywh: [f32; 4],
2495    radii: [f32; 4],
2496    fwd_mat: [f32; 4],
2497}
2498
2499/// Backdrop-blend composite instance: a `GlyphInstance`-shaped quad plus
2500/// the `BlendMode` discriminant consumed by `blend_layer.wgsl`.
2501#[repr(C)]
2502#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2503struct BlendInstance {
2504    xywh: [f32; 4],
2505    uv: [f32; 4],
2506    color: [f32; 4],
2507    fwd_mat: [f32; 4],
2508    mode: u32,
2509    _pad: [f32; 3],
2510}
2511
2512#[repr(C)]
2513#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2514struct MeshVertex {
2515    pos: [f32; 2],
2516    color: [f32; 4],
2517    uv: [f32; 2],
2518}
2519
2520#[derive(Clone)]
2521struct MeshGpu {
2522    vertices: wgpu::Buffer,
2523    indices: wgpu::Buffer,
2524    vertex_count: u32,
2525    index_count: u32,
2526    bytes: u64,
2527}
2528
2529#[derive(Clone, Copy, PartialEq, Eq, Hash)]
2530struct MeshCacheKey {
2531    source: usize,
2532    index: usize,
2533    kind: u8,
2534    vertex_len: usize,
2535    index_len: usize,
2536}
2537
2538#[derive(Clone)]
2539enum MeshOwner {
2540    Object(Arc<repose_core::VectorMeshData>),
2541    Overlay(Arc<[repose_core::VectorMeshData]>),
2542}
2543
2544struct MeshCacheEntry {
2545    owner: MeshOwner,
2546    gpu: MeshGpu,
2547    last_used_frame: u64,
2548    last_touch_frame: u64,
2549}
2550
2551#[derive(Clone, PartialEq, Eq, Hash)]
2552struct SlugDrawKey {
2553    cache_key: repose_text::CacheKey,
2554    fill: bool,
2555    /// The stroke parameters themselves, not a digest of them: a hash
2556    /// collision here would serve another stroke's tessellation.
2557    stroke: Option<slug::StrokeTessKey>,
2558    transform: [u32; 11],
2559    origin: [u32; 2],
2560    px_bits: u32,
2561    target: [u32; 2],
2562    color: [u32; 4],
2563}
2564
2565struct SlugDrawCacheEntry {
2566    vertices: Arc<[slug::TessVertex]>,
2567    bytes: u64,
2568    last_used_frame: u64,
2569    last_touch_frame: u64,
2570}
2571
2572/// Vector outline for one glyph, cached so repeated text at the same size does
2573/// not re-run outline extraction. `commands` is `None` for glyphs the extractor
2574/// rejects; that negative result is cached too, so a bad glyph is not retried.
2575struct GlyphOutlineEntry {
2576    commands: Option<Arc<[repose_text::Command]>>,
2577    last_touch: u64,
2578}
2579
2580/// Drop least-recently-used atlas entries until the live set fits `target_area`
2581/// pixels, skipping glyphs batched into the frame in flight. Never empties the
2582/// atlas, so a repack always has something left to pack around. Returns false
2583/// when nothing is evictable.
2584fn evict_atlas_lru(
2585    map: &mut HashMap<repose_text::CacheKey, GlyphInfo>,
2586    used_area: &mut u64,
2587    frame_index: u64,
2588    target_area: u64,
2589) -> bool {
2590    let mut victims: Vec<(u64, repose_text::CacheKey)> = map
2591        .iter()
2592        .filter(|(_, info)| info.last_used_frame != frame_index)
2593        .map(|(key, info)| (info.last_touch, key.clone()))
2594        .collect();
2595    if victims.is_empty() {
2596        return false;
2597    }
2598    // Ties are equally cold, so their relative order does not matter.
2599    victims.sort_unstable_by_key(|victim| victim.0);
2600    let mut evicted = false;
2601    for (_, key) in victims {
2602        if *used_area <= target_area || map.len() <= 1 {
2603            break;
2604        }
2605        if let Some(info) = map.remove(&key) {
2606            *used_area = used_area.saturating_sub(glyph_cell_area(&info));
2607            evicted = true;
2608        }
2609    }
2610    evicted
2611}
2612
2613/// Pixels a packed cell occupies. `w`/`h` are exact small integers by the time
2614/// they reach `GlyphInfo`, so the round trip through `f32` is lossless.
2615fn glyph_cell_area(info: &GlyphInfo) -> u64 {
2616    (info.w as u64).saturating_mul(info.h as u64)
2617}
2618
2619/// Remember that a glyph had no room, so it is skipped instead of re-rasterized
2620/// every frame until the next repack. Capped like the failure sets; dropping
2621/// the oldest entries only costs an earlier retry.
2622fn defer_atlas_glyph(deferred: &mut HashSet<repose_text::CacheKey>, key: repose_text::CacheKey) {
2623    if deferred.len() >= MAX_GLYPH_ATLAS_ENTRIES {
2624        deferred.clear();
2625    }
2626    deferred.insert(key);
2627}
2628
2629/// What one slug draw cache entry really costs: the vertex array plus the key,
2630/// the entry record and the map slot holding it. Charging only the array makes
2631/// the byte budget silently optimistic.
2632fn slug_draw_cache_entry_bytes(vertices: usize) -> u64 {
2633    let per_entry = (std::mem::size_of::<SlugDrawKey>()
2634        + std::mem::size_of::<SlugDrawCacheEntry>()
2635        + std::mem::size_of::<(SlugDrawKey, SlugDrawCacheEntry)>()) as u64;
2636    u64::try_from(vertices)
2637        .ok()
2638        .and_then(|count| count.checked_mul(std::mem::size_of::<slug::TessVertex>() as u64))
2639        .and_then(|payload| payload.checked_add(per_entry))
2640        .unwrap_or(u64::MAX)
2641}
2642
2643/// Per-draw vector mesh uniform. Field order and every member width are
2644/// load-bearing: the WGSL `MeshUniform` in `shaders/mesh.wgsl` must match it
2645/// member for member, and uniform address space forces 16-byte alignment, so
2646/// the two-point gradient endpoints are padded out to `vec4`s and the stop
2647/// table is one `vec4` per colour plus four offsets per `vec4` (never a bare
2648/// `array<f32>`). `paint.z` carries the live stop count (0..=8).
2649#[repr(C)]
2650#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2651struct MeshUniform {
2652    m0: [f32; 4],
2653    m1: [f32; 4],
2654    paint: [u32; 4],
2655    color0: [f32; 4],
2656    color1: [f32; 4],
2657    grad_start: [f32; 2],
2658    _p3: [f32; 2],
2659    grad_end: [f32; 2],
2660    _p4: [f32; 2],
2661    stop_colors: [[f32; 4]; MAX_GRADIENT_STOPS],
2662    stop_offsets: [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2663}
2664
2665/// Bytes `MeshUniform` occupies. `tests::mesh_uniform_matches_the_wgsl_member_layout`
2666/// parses `shaders/mesh.wgsl` and checks every member offset and the total
2667/// size against the struct, so the two halves cannot drift apart.
2668const MESH_UNIFORM_BYTES: usize = 272;
2669const _: () = assert!(std::mem::size_of::<MeshUniform>() == MESH_UNIFORM_BYTES);
2670
2671const MESH_UNIFORM_CAP: u64 = 4 * 1024 * 1024;
2672const MAX_RETAINED_IMAGE_BYTES: u64 = 256 * 1024 * 1024;
2673const MAX_RETAINED_IMAGES: usize = 512;
2674const MAX_BLEND_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
2675const MAX_BLEND_SNAPSHOT_POOL_ENTRIES: usize = 64;
2676const MAX_GLYPH_ATLAS_ENTRIES: usize = 32768;
2677/// Hard ceiling on atlas edge length. Past this the atlas is compacted instead
2678/// of grown, dropping least-recently-used glyphs.
2679const MAX_GLYPH_ATLAS_SIZE: u32 = 4096;
2680/// Fraction of the live set a compaction keeps. Trimming to a fraction of the
2681/// current area (rather than an absolute budget) guarantees a repack always
2682/// reclaims shelf space, even when the atlas is only a few large glyphs deep.
2683const ATLAS_COMPACT_KEEP_NUM: u64 = 3;
2684const ATLAS_COMPACT_KEEP_DEN: u64 = 4;
2685/// Minimum frames between compactions. A working set that genuinely exceeds
2686/// atlas capacity then costs a few missing glyphs per interval instead of
2687/// re-uploading the whole atlas every frame.
2688const ATLAS_COMPACT_MIN_FRAME_INTERVAL: u64 = 30;
2689const MAX_GPU_RESOURCE_BYTES: u64 = 1024 * 1024 * 1024;
2690const MAX_UPLOAD_RING_BYTES: u64 = 64 * 1024 * 1024;
2691const MAX_MESH_CACHE_BYTES: u64 = 256 * 1024 * 1024;
2692const MAX_MESH_CACHE_ENTRIES: usize = 512;
2693const MAX_SLUG_DRAW_CACHE_ENTRIES: usize = 2048;
2694const MAX_SLUG_DRAW_CACHE_BYTES: u64 = 32 * 1024 * 1024;
2695const MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES: usize = 256;
2696const MAX_GLYPH_OUTLINE_CACHE_ENTRIES: usize = 4096;
2697const MAX_IMAGE_DECODE_BYTES: u64 = 64 * 1024 * 1024;
2698const MAX_CALLBACK_SCOPES: usize = 256;
2699const NV12_FOURCC: u32 = 0x3231_564e;
2700const P010_FOURCC: u32 = 0x3031_3050;
2701
2702fn canonical_fourcc(format: PixelFormat) -> u32 {
2703    match format {
2704        PixelFormat::P010 => P010_FOURCC,
2705        _ => NV12_FOURCC,
2706    }
2707}
2708
2709impl MeshUniform {
2710    fn identity() -> Self {
2711        Self {
2712            m0: [1.0, 0.0, 0.0, 0.0],
2713            m1: [0.0, 1.0, 0.0, 0.0],
2714            paint: [0; 4],
2715            color0: [0.0; 4],
2716            color1: [0.0; 4],
2717            grad_start: [0.0; 2],
2718            _p3: [0.0; 2],
2719            grad_end: [0.0; 2],
2720            _p4: [0.0; 2],
2721            stop_colors: [[0.0; 4]; MAX_GRADIENT_STOPS],
2722            stop_offsets: [[0.0; 4]; MAX_GRADIENT_STOPS / 4],
2723        }
2724    }
2725}
2726
2727/// Colours, packed offsets and the live count, ready for `MeshUniform`.
2728type PackedStops = (
2729    [[f32; 4]; MAX_GRADIENT_STOPS],
2730    [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2731    u32,
2732);
2733
2734/// Packs a stop table for the uniform: one `vec4` per colour, four offsets per
2735/// `vec4`, plus the live count (0..=8). Offsets are clamped to `0..=1` and
2736/// forced non-decreasing so the shader can scan them in order without sorting;
2737/// a NaN offset folds to the previous stop's (`f32::max` drops NaN). Order is
2738/// otherwise preserved, so repeated offsets stay a hard stop at that position.
2739fn pack_gradient_stops(stops: &GradientStops) -> PackedStops {
2740    let mut stop_colors = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
2741    let mut flat = [0.0f32; MAX_GRADIENT_STOPS];
2742    let mut count = 0u32;
2743    let mut prev = 0.0f32;
2744    for ((color, offset), stop) in stop_colors
2745        .iter_mut()
2746        .zip(flat.iter_mut())
2747        .zip(stops.as_slice())
2748    {
2749        let clamped = stop.offset.clamp(0.0, 1.0).max(prev);
2750        prev = clamped;
2751        *color = stop.color.to_linear();
2752        *offset = clamped;
2753        count += 1;
2754    }
2755    let mut stop_offsets = [[0.0f32; 4]; MAX_GRADIENT_STOPS / 4];
2756    for (dst, src) in stop_offsets.iter_mut().zip(flat.as_chunks::<4>().0) {
2757        *dst = *src;
2758    }
2759    (stop_colors, stop_offsets, count)
2760}
2761
2762fn mesh_uniform_from_paint(affine: [f32; 6], paint: &repose_core::PaintDesc) -> MeshUniform {
2763    let no_stops = GradientStops::default();
2764    let (paint_type, paint_kind, stops, color0, color1, grad_start, grad_end) = match paint {
2765        repose_core::PaintDesc::Solid => {
2766            (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2])
2767        }
2768        repose_core::PaintDesc::Linear {
2769            start,
2770            end,
2771            start_color,
2772            end_color,
2773        } => (
2774            1u32,
2775            0u32,
2776            no_stops,
2777            start_color.to_linear(),
2778            end_color.to_linear(),
2779            [start.x, start.y],
2780            [end.x, end.y],
2781        ),
2782        repose_core::PaintDesc::Radial {
2783            center,
2784            radius,
2785            start_color,
2786            end_color,
2787        } => (
2788            1u32,
2789            1u32,
2790            no_stops,
2791            start_color.to_linear(),
2792            end_color.to_linear(),
2793            [center.x, center.y],
2794            [radius.max(0.0), 0.0],
2795        ),
2796        repose_core::PaintDesc::Sweep {
2797            center,
2798            start_color,
2799            end_color,
2800        } => (
2801            1u32,
2802            2u32,
2803            no_stops,
2804            start_color.to_linear(),
2805            end_color.to_linear(),
2806            [center.x, center.y],
2807            [0.0, 0.0],
2808        ),
2809        repose_core::PaintDesc::LinearStops { start, end, stops } => (
2810            1u32,
2811            3u32,
2812            *stops,
2813            [0.0; 4],
2814            [0.0; 4],
2815            [start.x, start.y],
2816            [end.x, end.y],
2817        ),
2818        repose_core::PaintDesc::RadialStops {
2819            center,
2820            radius,
2821            stops,
2822        } => (
2823            1u32,
2824            4u32,
2825            *stops,
2826            [0.0; 4],
2827            [0.0; 4],
2828            [center.x, center.y],
2829            [radius.max(0.0), 0.0],
2830        ),
2831        _ => (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
2832    };
2833    let (stop_colors, stop_offsets, count) = pack_gradient_stops(&stops);
2834    MeshUniform {
2835        m0: [affine[0], affine[1], affine[2], 0.0],
2836        m1: [affine[3], affine[4], affine[5], 0.0],
2837        paint: [paint_type, paint_kind, count, 0],
2838        color0,
2839        color1,
2840        grad_start,
2841        _p3: [0.0; 2],
2842        grad_end,
2843        _p4: [0.0; 2],
2844        stop_colors,
2845        stop_offsets,
2846    }
2847}
2848
2849fn combine_mesh_affine(current: &Transform, mesh: [f32; 6]) -> [f32; 6] {
2850    let cm = current.linear();
2851    let (cm00, cm01, cm10, cm11) = (cm[0], cm[1], cm[2], cm[3]);
2852    let mm00 = mesh[0];
2853    let mm01 = mesh[1];
2854    let mm10 = mesh[2];
2855    let mm11 = mesh[3];
2856    let mtx = mesh[4];
2857    let mty = mesh[5];
2858    let r00 = cm00 * mm00 + cm01 * mm10;
2859    let r01 = cm00 * mm01 + cm01 * mm11;
2860    let r10 = cm10 * mm00 + cm11 * mm10;
2861    let r11 = cm10 * mm01 + cm11 * mm11;
2862    let tx = cm00 * mtx + cm01 * mty + current.translate_x;
2863    let ty = cm10 * mtx + cm11 * mty + current.translate_y;
2864    // Canonical slot order consumed by `MeshUniform`/shader and `mesh_aabb`:
2865    // [A, B, tx, C, D, ty] where world = [[A,B],[C,D]] * local + (tx, ty).
2866    [r00, r01, tx, r10, r11, ty]
2867}
2868
2869fn mesh_aabb(mesh: &repose_core::VectorMeshData, affine: [f32; 6]) -> repose_core::Rect {
2870    let mut min_x = f32::INFINITY;
2871    let mut min_y = f32::INFINITY;
2872    let mut max_x = f32::NEG_INFINITY;
2873    let mut max_y = f32::NEG_INFINITY;
2874    for v in mesh.vertices.iter() {
2875        let x = affine[0] * v.pos[0] + affine[1] * v.pos[1] + affine[2];
2876        let y = affine[3] * v.pos[0] + affine[4] * v.pos[1] + affine[5];
2877        min_x = min_x.min(x);
2878        min_y = min_y.min(y);
2879        max_x = max_x.max(x);
2880        max_y = max_y.max(y);
2881    }
2882    let w = (max_x - min_x).max(0.0);
2883    let h = (max_y - min_y).max(0.0);
2884    if !min_x.is_finite() || !min_y.is_finite() {
2885        return repose_core::Rect {
2886            x: 0.0,
2887            y: 0.0,
2888            w: 0.0,
2889            h: 0.0,
2890        };
2891    }
2892    repose_core::Rect {
2893        x: min_x,
2894        y: min_y,
2895        w,
2896        h,
2897    }
2898}
2899
2900fn quantize_glyph_px(px: f32) -> f32 {
2901    if !px.is_finite() || px <= 0.0 {
2902        return 1.0;
2903    }
2904    ((px * 4.0).round() / 4.0).max(0.25)
2905}
2906
2907fn swash_to_a8_coverage(content: repose_text::SwashContent, data: &[u8]) -> Option<Vec<u8>> {
2908    match content {
2909        repose_text::SwashContent::Mask => Some(data.to_vec()),
2910        repose_text::SwashContent::SubpixelMask => {
2911            let mut out = Vec::with_capacity(data.len() / 4);
2912            for px in data.as_chunks::<4>().0 {
2913                let r = px[0];
2914                let g = px[1];
2915                let b = px[2];
2916                out.push(r.max(g).max(b));
2917            }
2918            Some(out)
2919        }
2920        repose_text::SwashContent::Color => None,
2921    }
2922}
2923
2924impl WgpuSceneRenderer {
2925    /// Construct a renderer for `output_format` with `msaa_samples` samples.
2926    ///
2927    /// Validates what is checkable without an adapter: `wgpu` only accepts
2928    /// sample counts of 1, 2, 4, 8 or 16, and an unrenderable or
2929    /// unblendable output format fails later as a validation error rather
2930    /// than here. Prefer
2931    /// [`try_from_device_with_adapter`](Self::try_from_device_with_adapter)
2932    /// whenever an adapter is available - it also checks the format's
2933    /// render-attachment, blend and multisample-resolve support and picks a
2934    /// supported sample count.
2935    pub fn from_device(
2936        device: wgpu::Device,
2937        queue: wgpu::Queue,
2938        output_format: wgpu::TextureFormat,
2939        msaa_samples: u32,
2940    ) -> anyhow::Result<Self> {
2941        validate_sample_count("msaa_samples", msaa_samples)?;
2942        Self::from_device_with_working_space_msaa(
2943            device,
2944            queue,
2945            output_format,
2946            msaa_samples,
2947            msaa_samples,
2948        )
2949    }
2950
2951    /// Validated constructor with an adapter: rejects an output format that
2952    /// cannot back the UI pipelines and clamps `requested_msaa` to the
2953    /// largest supported count at or below it.
2954    pub fn try_from_device_with_adapter(
2955        adapter: &wgpu::Adapter,
2956        device: wgpu::Device,
2957        queue: wgpu::Queue,
2958        output_format: wgpu::TextureFormat,
2959        requested_msaa: u32,
2960    ) -> anyhow::Result<Self> {
2961        let msaa = pick_surface_msaa(adapter, output_format, requested_msaa);
2962        validate_render_target_format(adapter, output_format, msaa)?;
2963        let working_space_msaa =
2964            pick_surface_msaa_for_mode(adapter, wgpu::TextureFormat::Rgba16Float, msaa, true);
2965        Self::from_device_with_working_space_msaa(
2966            device,
2967            queue,
2968            output_format,
2969            msaa,
2970            working_space_msaa,
2971        )
2972    }
2973
2974    /// Point the renderer at a surface whose texture format differs from
2975    /// [`Self::output_format`], as a canvas configured with `viewFormats` does.
2976    /// Call this before the first frame.
2977    pub fn with_surface_format(
2978        mut self,
2979        surface_format: wgpu::TextureFormat,
2980    ) -> anyhow::Result<Self> {
2981        if surface_format != self.output_format
2982            && surface_format.add_srgb_suffix() != self.output_format
2983        {
2984            anyhow::bail!(
2985                "surface format {surface_format:?} cannot be reinterpreted as {:?}",
2986                self.output_format
2987            );
2988        }
2989        if self.surface_format != surface_format {
2990            self.surface_format = surface_format;
2991            self.recreate_msaa_and_depth_stencil();
2992        }
2993        Ok(self)
2994    }
2995
2996    pub fn from_device_with_working_space_msaa(
2997        device: wgpu::Device,
2998        queue: wgpu::Queue,
2999        output_format: wgpu::TextureFormat,
3000        msaa_samples: u32,
3001        working_space_msaa_samples: u32,
3002    ) -> anyhow::Result<Self> {
3003        validate_sample_count("msaa_samples", msaa_samples)?;
3004        validate_sample_count("working_space_msaa_samples", working_space_msaa_samples)?;
3005        let msaa_samples = msaa_samples.max(1);
3006        let working_space_msaa_samples = working_space_msaa_samples.max(1);
3007        let mesh_uniform_size = std::mem::size_of::<MeshUniform>() as u64;
3008        let base_mesh_alignment =
3009            u64::from(device.limits().min_uniform_buffer_offset_alignment).max(4);
3010        let mesh_uniform_alignment =
3011            align_up(mesh_uniform_size, base_mesh_alignment).unwrap_or(base_mesh_alignment);
3012        let mesh_uniform_cap = align_up(
3013            MESH_UNIFORM_CAP
3014                .min(device.limits().max_buffer_size)
3015                .max(mesh_uniform_alignment),
3016            mesh_uniform_alignment,
3017        )
3018        .unwrap_or(mesh_uniform_alignment);
3019        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3020            label: Some("globals layout"),
3021            entries: &[wgpu::BindGroupLayoutEntry {
3022                binding: 0,
3023                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3024                ty: wgpu::BindingType::Buffer {
3025                    ty: wgpu::BufferBindingType::Uniform,
3026                    has_dynamic_offset: false,
3027                    min_binding_size: None,
3028                },
3029                count: None,
3030            }],
3031        });
3032
3033        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
3034            label: Some("globals buf"),
3035            size: std::mem::size_of::<Globals>() as u64,
3036            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3037            mapped_at_creation: false,
3038        });
3039
3040        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3041            label: Some("globals bind"),
3042            layout: &globals_layout,
3043            entries: &[wgpu::BindGroupEntry {
3044                binding: 0,
3045                resource: globals_buf.as_entire_binding(),
3046            }],
3047        });
3048        let globals_staging_cap =
3049            (64 * std::mem::size_of::<Globals>() as u64).max(std::mem::size_of::<Globals>() as u64);
3050        let globals_staging = device.create_buffer(&wgpu::BufferDescriptor {
3051            label: Some("globals staging"),
3052            size: globals_staging_cap,
3053            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
3054            mapped_at_creation: false,
3055        });
3056
3057        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
3058
3059        let stencil_for_content = wgpu::DepthStencilState {
3060            format: ds_format,
3061            depth_write_enabled: Some(false),
3062            depth_compare: Some(wgpu::CompareFunction::Always),
3063            stencil: wgpu::StencilState {
3064                front: wgpu::StencilFaceState {
3065                    // Equal (not LessEqual): inverse (`Difference`) vector
3066                    // masks work by keeping the depth while incrementing the
3067                    // masked pixels, so content must test exact equality.
3068                    // Outcomes match LessEqual everywhere except pre-existing
3069                    // stencil leaks, which now fail visibly instead of
3070                    // drawing through (unbalanced clips already warn).
3071                    compare: wgpu::CompareFunction::Equal,
3072                    fail_op: wgpu::StencilOperation::Keep,
3073                    depth_fail_op: wgpu::StencilOperation::Keep,
3074                    pass_op: wgpu::StencilOperation::Keep,
3075                },
3076                back: wgpu::StencilFaceState {
3077                    compare: wgpu::CompareFunction::Equal,
3078                    fail_op: wgpu::StencilOperation::Keep,
3079                    depth_fail_op: wgpu::StencilOperation::Keep,
3080                    pass_op: wgpu::StencilOperation::Keep,
3081                },
3082                read_mask: 0xFF,
3083                write_mask: 0x00,
3084            },
3085            bias: wgpu::DepthBiasState::default(),
3086        };
3087
3088        let stencil_for_clip_inc = wgpu::DepthStencilState {
3089            format: ds_format,
3090            depth_write_enabled: Some(false),
3091            depth_compare: Some(wgpu::CompareFunction::Always),
3092            stencil: wgpu::StencilState {
3093                front: wgpu::StencilFaceState {
3094                    compare: wgpu::CompareFunction::Always,
3095                    fail_op: wgpu::StencilOperation::Keep,
3096                    depth_fail_op: wgpu::StencilOperation::Keep,
3097                    pass_op: wgpu::StencilOperation::IncrementClamp,
3098                },
3099                back: wgpu::StencilFaceState {
3100                    compare: wgpu::CompareFunction::Always,
3101                    fail_op: wgpu::StencilOperation::Keep,
3102                    depth_fail_op: wgpu::StencilOperation::Keep,
3103                    pass_op: wgpu::StencilOperation::IncrementClamp,
3104                },
3105                read_mask: 0xFF,
3106                write_mask: 0xFF,
3107            },
3108            bias: wgpu::DepthBiasState::default(),
3109        };
3110
3111        let stencil_for_clip_dec = wgpu::DepthStencilState {
3112            format: ds_format,
3113            depth_write_enabled: Some(false),
3114            depth_compare: Some(wgpu::CompareFunction::Always),
3115            stencil: wgpu::StencilState {
3116                front: wgpu::StencilFaceState {
3117                    compare: wgpu::CompareFunction::Always,
3118                    fail_op: wgpu::StencilOperation::Keep,
3119                    depth_fail_op: wgpu::StencilOperation::Keep,
3120                    pass_op: wgpu::StencilOperation::DecrementClamp,
3121                },
3122                back: wgpu::StencilFaceState {
3123                    compare: wgpu::CompareFunction::Always,
3124                    fail_op: wgpu::StencilOperation::Keep,
3125                    depth_fail_op: wgpu::StencilOperation::Keep,
3126                    pass_op: wgpu::StencilOperation::DecrementClamp,
3127                },
3128                read_mask: 0xFF,
3129                write_mask: 0xFF,
3130            },
3131            bias: wgpu::DepthBiasState::default(),
3132        };
3133
3134        let _multisample_state = wgpu::MultisampleState {
3135            count: msaa_samples,
3136            mask: !0,
3137            alpha_to_coverage_enabled: false,
3138        };
3139
3140        // PIPELINES
3141
3142        // Single shared sampler for images/text
3143        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3144            label: Some("image/text sampler"),
3145            address_mode_u: wgpu::AddressMode::ClampToEdge,
3146            address_mode_v: wgpu::AddressMode::ClampToEdge,
3147            mag_filter: wgpu::FilterMode::Linear,
3148            min_filter: wgpu::FilterMode::Linear,
3149            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3150            ..Default::default()
3151        });
3152        let image_sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3153            label: Some("image nearest sampler"),
3154            address_mode_u: wgpu::AddressMode::ClampToEdge,
3155            address_mode_v: wgpu::AddressMode::ClampToEdge,
3156            mag_filter: wgpu::FilterMode::Nearest,
3157            min_filter: wgpu::FilterMode::Nearest,
3158            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3159            ..Default::default()
3160        });
3161
3162        // `ImageFit::Tile` emits UVs past 1.0 and relies on wrapping, so it
3163        // needs its own samplers rather than the clamped pair above.
3164        let image_sampler_repeat = device.create_sampler(&wgpu::SamplerDescriptor {
3165            label: Some("image repeat sampler"),
3166            address_mode_u: wgpu::AddressMode::Repeat,
3167            address_mode_v: wgpu::AddressMode::Repeat,
3168            mag_filter: wgpu::FilterMode::Linear,
3169            min_filter: wgpu::FilterMode::Linear,
3170            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3171            ..Default::default()
3172        });
3173        let image_sampler_repeat_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3174            label: Some("image repeat nearest sampler"),
3175            address_mode_u: wgpu::AddressMode::Repeat,
3176            address_mode_v: wgpu::AddressMode::Repeat,
3177            mag_filter: wgpu::FilterMode::Nearest,
3178            min_filter: wgpu::FilterMode::Nearest,
3179            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3180            ..Default::default()
3181        });
3182
3183        // linear filtering only blurs them; nearest keeps the blit crisp.
3184        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3185            label: Some("layer nearest sampler"),
3186            address_mode_u: wgpu::AddressMode::ClampToEdge,
3187            address_mode_v: wgpu::AddressMode::ClampToEdge,
3188            mag_filter: wgpu::FilterMode::Nearest,
3189            min_filter: wgpu::FilterMode::Nearest,
3190            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3191            ..Default::default()
3192        });
3193
3194        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
3195        // the sharp 1:1 layer composite.
3196        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
3197            label: Some("layer linear sampler"),
3198            address_mode_u: wgpu::AddressMode::ClampToEdge,
3199            address_mode_v: wgpu::AddressMode::ClampToEdge,
3200            mag_filter: wgpu::FilterMode::Linear,
3201            min_filter: wgpu::FilterMode::Linear,
3202            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3203            ..Default::default()
3204        });
3205
3206        // Layout for Text / RGBA Images (Texture + Sampler)
3207        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3208            label: Some("text/rgba bind layout"),
3209            entries: &[
3210                wgpu::BindGroupLayoutEntry {
3211                    binding: 0,
3212                    visibility: wgpu::ShaderStages::FRAGMENT,
3213                    ty: wgpu::BindingType::Texture {
3214                        multisampled: false,
3215                        view_dimension: wgpu::TextureViewDimension::D2,
3216                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3217                    },
3218                    count: None,
3219                },
3220                wgpu::BindGroupLayoutEntry {
3221                    binding: 1,
3222                    visibility: wgpu::ShaderStages::FRAGMENT,
3223                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3224                    count: None,
3225                },
3226            ],
3227        });
3228        // We reuse this for RGBA images for simplicity, or create a distinct one
3229        let image_bind_layout_rgba = text_bind_layout.clone();
3230
3231        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
3232        let image_bind_layout_nv12 =
3233            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3234                label: Some("image bind layout nv12"),
3235                entries: &[
3236                    // Y plane
3237                    wgpu::BindGroupLayoutEntry {
3238                        binding: 0,
3239                        visibility: wgpu::ShaderStages::FRAGMENT,
3240                        ty: wgpu::BindingType::Texture {
3241                            multisampled: false,
3242                            view_dimension: wgpu::TextureViewDimension::D2,
3243                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3244                        },
3245                        count: None,
3246                    },
3247                    // UV plane
3248                    wgpu::BindGroupLayoutEntry {
3249                        binding: 1,
3250                        visibility: wgpu::ShaderStages::FRAGMENT,
3251                        ty: wgpu::BindingType::Texture {
3252                            multisampled: false,
3253                            view_dimension: wgpu::TextureViewDimension::D2,
3254                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3255                        },
3256                        count: None,
3257                    },
3258                    // Sampler
3259                    wgpu::BindGroupLayoutEntry {
3260                        binding: 2,
3261                        visibility: wgpu::ShaderStages::FRAGMENT,
3262                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3263                        count: None,
3264                    },
3265                    // YUV transform uniform buffer
3266                    wgpu::BindGroupLayoutEntry {
3267                        binding: 3,
3268                        visibility: wgpu::ShaderStages::FRAGMENT,
3269                        ty: wgpu::BindingType::Buffer {
3270                            ty: wgpu::BufferBindingType::Uniform,
3271                            has_dynamic_offset: false,
3272                            min_binding_size: None,
3273                        },
3274                        count: None,
3275                    },
3276                ],
3277            });
3278
3279        // Clipping layout
3280        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3281            label: Some("clip pipeline layout"),
3282            bind_group_layouts: &[Some(&globals_layout)],
3283            immediate_size: 0,
3284        });
3285        let clip_vertex_layout = wgpu::VertexBufferLayout {
3286            array_stride: std::mem::size_of::<ClipInstance>() as u64,
3287            step_mode: wgpu::VertexStepMode::Instance,
3288            attributes: &[
3289                wgpu::VertexAttribute {
3290                    shader_location: 0,
3291                    offset: 0,
3292                    format: wgpu::VertexFormat::Float32x4,
3293                },
3294                wgpu::VertexAttribute {
3295                    shader_location: 1,
3296                    offset: 16,
3297                    format: wgpu::VertexFormat::Float32x4,
3298                },
3299                wgpu::VertexAttribute {
3300                    shader_location: 2,
3301                    offset: 32,
3302                    format: wgpu::VertexFormat::Float32x4,
3303                },
3304            ],
3305        };
3306        // Bind layout for per-draw vector mesh uniforms (dynamic offset).
3307        let mesh_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3308            label: Some("mesh uniform layout"),
3309            entries: &[wgpu::BindGroupLayoutEntry {
3310                binding: 0,
3311                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3312                ty: wgpu::BindingType::Buffer {
3313                    ty: wgpu::BufferBindingType::Uniform,
3314                    has_dynamic_offset: true,
3315                    min_binding_size: NonZeroU64::new(mesh_uniform_size),
3316                },
3317                count: None,
3318            }],
3319        });
3320        let mesh_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3321            label: Some("mesh uniform buffer"),
3322            size: mesh_uniform_cap,
3323            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3324            mapped_at_creation: false,
3325        });
3326        let mesh_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3327            label: Some("mesh uniform bind"),
3328            layout: &mesh_bind_layout,
3329            entries: &[wgpu::BindGroupEntry {
3330                binding: 0,
3331                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3332                    buffer: &mesh_uniform_buf,
3333                    offset: 0,
3334                    size: NonZeroU64::new(mesh_uniform_size),
3335                }),
3336            }],
3337        });
3338
3339        // Two sets of pipelines: one for the MSAA surface pass, one for layer
3340        // render-to-texture passes (sample_count = 1).
3341        let surface_pipes = Pipelines::create(
3342            &device,
3343            output_format,
3344            msaa_samples,
3345            &globals_layout,
3346            &text_bind_layout,
3347            &image_bind_layout_nv12,
3348            &clip_pipeline_layout,
3349            &stencil_for_content,
3350            &stencil_for_clip_inc,
3351            &stencil_for_clip_dec,
3352            &clip_vertex_layout,
3353            &mesh_bind_layout,
3354        );
3355        let working_space_pipes = Pipelines::create(
3356            &device,
3357            wgpu::TextureFormat::Rgba16Float,
3358            working_space_msaa_samples,
3359            &globals_layout,
3360            &text_bind_layout,
3361            &image_bind_layout_nv12,
3362            &clip_pipeline_layout,
3363            &stencil_for_content,
3364            &stencil_for_clip_inc,
3365            &stencil_for_clip_dec,
3366            &clip_vertex_layout,
3367            &mesh_bind_layout,
3368        );
3369        let layer_pipes = if msaa_samples == 1 {
3370            surface_pipes.clone()
3371        } else {
3372            Pipelines::create(
3373                &device,
3374                output_format,
3375                1,
3376                &globals_layout,
3377                &text_bind_layout,
3378                &image_bind_layout_nv12,
3379                &clip_pipeline_layout,
3380                &stencil_for_content,
3381                &stencil_for_clip_inc,
3382                &stencil_for_clip_dec,
3383                &clip_vertex_layout,
3384                &mesh_bind_layout,
3385            )
3386        };
3387        let working_space_layer_pipes = if working_space_msaa_samples == 1 {
3388            working_space_pipes.clone()
3389        } else {
3390            Pipelines::create(
3391                &device,
3392                wgpu::TextureFormat::Rgba16Float,
3393                1,
3394                &globals_layout,
3395                &text_bind_layout,
3396                &image_bind_layout_nv12,
3397                &clip_pipeline_layout,
3398                &stencil_for_content,
3399                &stencil_for_clip_inc,
3400                &stencil_for_clip_dec,
3401                &clip_vertex_layout,
3402                &mesh_bind_layout,
3403            )
3404        };
3405
3406        // Vector glyph rendering always available with tessellation+MSAA approach.
3407        let slug_enabled = true;
3408
3409        // Blur composite ring (for graphics-layer drop shadows)
3410        let blur_ring = UploadRing::new(
3411            &device,
3412            "blur ring",
3413            1024 * 1024,
3414            wgpu::BufferUsages::VERTEX
3415                | wgpu::BufferUsages::COPY_DST
3416                | wgpu::BufferUsages::COPY_SRC,
3417        );
3418
3419        // Atlases
3420        let atlas_mask = init_atlas_mask(&device);
3421        let atlas_color = init_atlas_color(&device);
3422        let atlas_mask_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3423            label: Some("atlas mask bind"),
3424            layout: &text_bind_layout,
3425            entries: &[
3426                wgpu::BindGroupEntry {
3427                    binding: 0,
3428                    resource: wgpu::BindingResource::TextureView(&atlas_mask.view),
3429                },
3430                wgpu::BindGroupEntry {
3431                    binding: 1,
3432                    resource: wgpu::BindingResource::Sampler(&atlas_mask.sampler),
3433                },
3434            ],
3435        });
3436        let atlas_color_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3437            label: Some("atlas color bind"),
3438            layout: &text_bind_layout,
3439            entries: &[
3440                wgpu::BindGroupEntry {
3441                    binding: 0,
3442                    resource: wgpu::BindingResource::TextureView(&atlas_color.view),
3443                },
3444                wgpu::BindGroupEntry {
3445                    binding: 1,
3446                    resource: wgpu::BindingResource::Sampler(&atlas_color.sampler),
3447                },
3448            ],
3449        });
3450
3451        // Upload rings
3452        let ring_rect = UploadRing::new(
3453            &device,
3454            "ring rect",
3455            1 << 20,
3456            wgpu::BufferUsages::VERTEX
3457                | wgpu::BufferUsages::COPY_DST
3458                | wgpu::BufferUsages::COPY_SRC,
3459        );
3460        let ring_border = UploadRing::new(
3461            &device,
3462            "ring border",
3463            1 << 20,
3464            wgpu::BufferUsages::VERTEX
3465                | wgpu::BufferUsages::COPY_DST
3466                | wgpu::BufferUsages::COPY_SRC,
3467        );
3468        let ring_ellipse = UploadRing::new(
3469            &device,
3470            "ring ellipse",
3471            1 << 20,
3472            wgpu::BufferUsages::VERTEX
3473                | wgpu::BufferUsages::COPY_DST
3474                | wgpu::BufferUsages::COPY_SRC,
3475        );
3476        let ring_ellipse_border = UploadRing::new(
3477            &device,
3478            "ring ellipse border",
3479            1 << 20,
3480            wgpu::BufferUsages::VERTEX
3481                | wgpu::BufferUsages::COPY_DST
3482                | wgpu::BufferUsages::COPY_SRC,
3483        );
3484        let ring_arc = UploadRing::new(
3485            &device,
3486            "ring arc",
3487            1 << 20,
3488            wgpu::BufferUsages::VERTEX
3489                | wgpu::BufferUsages::COPY_DST
3490                | wgpu::BufferUsages::COPY_SRC,
3491        );
3492        let ring_glyph_mask = UploadRing::new(
3493            &device,
3494            "ring glyph mask",
3495            1 << 20,
3496            wgpu::BufferUsages::VERTEX
3497                | wgpu::BufferUsages::COPY_DST
3498                | wgpu::BufferUsages::COPY_SRC,
3499        );
3500        let ring_glyph_color = UploadRing::new(
3501            &device,
3502            "ring glyph color",
3503            1 << 20,
3504            wgpu::BufferUsages::VERTEX
3505                | wgpu::BufferUsages::COPY_DST
3506                | wgpu::BufferUsages::COPY_SRC,
3507        );
3508        let ring_slug = UploadRing::new(
3509            &device,
3510            "ring slug",
3511            1 << 22,
3512            wgpu::BufferUsages::VERTEX
3513                | wgpu::BufferUsages::COPY_DST
3514                | wgpu::BufferUsages::COPY_SRC,
3515        );
3516        let ring_clip = UploadRing::new(
3517            &device,
3518            "ring clip",
3519            1 << 16,
3520            wgpu::BufferUsages::VERTEX
3521                | wgpu::BufferUsages::COPY_DST
3522                | wgpu::BufferUsages::COPY_SRC,
3523        );
3524        let blend_ring = UploadRing::new(
3525            &device,
3526            "ring blend",
3527            1 << 16,
3528            wgpu::BufferUsages::VERTEX
3529                | wgpu::BufferUsages::COPY_DST
3530                | wgpu::BufferUsages::COPY_SRC,
3531        );
3532        let ring_projective = UploadRing::new(
3533            &device,
3534            "ring projective",
3535            1 << 16,
3536            wgpu::BufferUsages::VERTEX
3537                | wgpu::BufferUsages::COPY_DST
3538                | wgpu::BufferUsages::COPY_SRC,
3539        );
3540        let ring_nv12 = UploadRing::new(
3541            &device,
3542            "ring nv12",
3543            1 << 20,
3544            wgpu::BufferUsages::VERTEX
3545                | wgpu::BufferUsages::COPY_DST
3546                | wgpu::BufferUsages::COPY_SRC,
3547        );
3548        // Placeholder textures
3549        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
3550            label: Some("temp ds"),
3551            size: wgpu::Extent3d {
3552                width: 1,
3553                height: 1,
3554                depth_or_array_layers: 1,
3555            },
3556            mip_level_count: 1,
3557            sample_count: 1,
3558            dimension: wgpu::TextureDimension::D2,
3559            format: wgpu::TextureFormat::Depth24PlusStencil8,
3560            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3561            view_formats: &[],
3562        });
3563        let depth_stencil_view =
3564            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
3565
3566        let mut renderer = WgpuSceneRenderer {
3567            device,
3568            queue,
3569            output_format,
3570            surface_format: output_format,
3571            output_width: 0,
3572            output_height: 0,
3573            pixels_per_point: 1.0,
3574
3575            surface_pipes,
3576            working_space_pipes,
3577            layer_pipes,
3578            working_space_layer_pipes,
3579
3580            rects: InstancedPipe::new(ring_rect),
3581            borders: InstancedPipe::new(ring_border),
3582            ellipses: InstancedPipe::new(ring_ellipse),
3583            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
3584            arcs: InstancedPipe::new(ring_arc),
3585            glyph_mask: InstancedPipe::new(ring_glyph_mask),
3586            glyph_color: InstancedPipe::new(ring_glyph_color),
3587
3588            text_bind_layout,
3589
3590            image_bind_layout_rgba,
3591            image_bind_layout_nv12,
3592            image_sampler,
3593            image_sampler_nearest,
3594            image_sampler_repeat,
3595            image_sampler_repeat_nearest,
3596            layer_sampler,
3597            layer_sampler_linear,
3598
3599            blur_ring,
3600
3601            slug_enabled,
3602            slug_ring: ring_slug,
3603            slug_cache: slug::GlyphSlugCache::new(),
3604            slug_draw_cache: HashMap::new(),
3605            slug_draw_cache_bytes_total: 0,
3606
3607            clip_ring: ring_clip,
3608
3609            nv12: InstancedPipe::new(ring_nv12),
3610
3611            mesh_cache: HashMap::new(),
3612            mesh_cache_bytes_total: 0,
3613            mesh_uniform_buf,
3614            mesh_bind,
3615            mesh_uniform_head: 0,
3616            mesh_uniform_alignment,
3617            mesh_uniform_cap,
3618
3619            projective_ring: ring_projective,
3620            blend_ring,
3621            flatten_layer_ids: Vec::new(),
3622            blur_scratch_ids: HashMap::new(),
3623            blur_scratch_next: 0,
3624            blend_snapshots: std::collections::HashMap::new(),
3625            blend_snapshot_pool: HashMap::new(),
3626            blend_copies: Vec::new(),
3627
3628            msaa_samples,
3629            working_space_msaa_samples,
3630            depth_stencil_tex,
3631            depth_stencil_view,
3632            msaa_tex: None,
3633            msaa_view: None,
3634            ws_msaa_tex: None,
3635            ws_msaa_view: None,
3636            surface_resolve_tex: None,
3637            surface_resolve_view: None,
3638            surface_resolve_bytes: 0,
3639            msaa_bytes: 0,
3640            ws_msaa_bytes: 0,
3641            depth_stencil_bytes: 0,
3642            working_space_bytes: 0,
3643            gpu_budget_bytes: MAX_GPU_RESOURCE_BYTES,
3644            globals_bind,
3645            globals_buf,
3646            globals_staging,
3647            globals_staging_cap,
3648
3649            atlas_mask,
3650            atlas_color,
3651            atlas_mask_bind,
3652            atlas_color_bind,
3653            atlas_generation: repose_text::font_generation(),
3654            atlas_mask_pending: AtlasPending::default(),
3655            atlas_color_pending: AtlasPending::default(),
3656            atlas_last_compact_frame: 0,
3657            atlas_mask_failures: HashSet::new(),
3658            atlas_color_failures: HashSet::new(),
3659            atlas_mask_deferred: HashSet::new(),
3660            atlas_color_deferred: HashSet::new(),
3661            glyph_outline_cache: HashMap::new(),
3662            legacy_shaped_text_cache: HashMap::new(),
3663
3664            next_image_handle: 1,
3665            images: HashMap::new(),
3666            retained: HashMap::new(),
3667            retained_bytes_total: 0,
3668
3669            next_coverage_handle: 1,
3670            coverages: HashMap::new(),
3671
3672            frame_index: 0,
3673            cache_touch_clock: 0,
3674            image_bytes_total: 0,
3675            image_evict_after_frames: 600,         // ~10s @ 60fps
3676            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
3677            layer_pool: HashMap::new(),
3678            producer_layer_ids: HashSet::new(),
3679            layer_bytes_total: 0,
3680            transient_layer_bytes_total: 0,
3681
3682            working_space: false,
3683            ws_tex: None,
3684            ws_view: None,
3685            ws_bind: None,
3686            display_pipeline: None,
3687            display_layout: None,
3688
3689            callback_resources: CallbackResources::default(),
3690            callback_scoped_resources: HashMap::new(),
3691            callback_scope_uses: HashMap::new(),
3692            callback_scope_payloads: HashMap::new(),
3693            callback_scope_clock: 0,
3694            blend_snapshot_bytes_total: 0,
3695            frame_active: false,
3696            last_render_error: None,
3697        };
3698
3699        renderer.recreate_msaa_and_depth_stencil();
3700        Ok(renderer)
3701    }
3702}
3703
3704/// wgpu backend names accepted by [`parse_backends`], mapped from the
3705/// canonical [`wgpu::Backend::to_str`] spelling plus the aliases people
3706/// actually type.
3707#[cfg(not(target_arch = "wasm32"))]
3708fn backend_by_name(name: &str) -> Option<wgpu::Backend> {
3709    match name {
3710        "gl" | "opengl" | "opengles" | "webgl" => Some(wgpu::Backend::Gl),
3711        "webgpu" | "browser-webgpu" => Some(wgpu::Backend::BrowserWebGpu),
3712        "metal" => Some(wgpu::Backend::Metal),
3713        "vulkan" => Some(wgpu::Backend::Vulkan),
3714        "dx12" | "d3d12" => Some(wgpu::Backend::Dx12),
3715        _ => None,
3716    }
3717}
3718
3719/// Parse a comma/space separated wgpu backend list such as `gl` or
3720/// `vulkan,gl`. Unknown names are dropped with a warning rather than
3721/// failing the whole spec, and `None` means "nothing valid was requested".
3722#[cfg(not(target_arch = "wasm32"))]
3723pub fn parse_backends(spec: &str) -> Option<wgpu::Backends> {
3724    let mut bits = wgpu::Backends::empty();
3725    for name in spec.split([',', ' ', '+']).filter(|n| !n.is_empty()) {
3726        match backend_by_name(&name.to_ascii_lowercase()) {
3727            Some(backend) => bits |= wgpu::Backends::from(backend),
3728            None => log::warn!(
3729                "REPOSE_WGPU_BACKENDS: ignoring unknown backend {name:?}; \
3730                 expected one of noop, vulkan, metal, dx12, gl, webgpu"
3731            ),
3732        }
3733    }
3734    (!bits.is_empty()).then_some(bits)
3735}
3736
3737#[cfg(feature = "winit-surface")]
3738pub(crate) async fn surface_instance(
3739    display: Option<winit::event_loop::OwnedDisplayHandle>,
3740) -> (Instance, wgpu::Backends) {
3741    // The display handle is what lets wgpu's GLES backend reach a real
3742    // platform surface. Without it wgpu falls back to a surfaceless/legacy-EGL
3743    // display, and on Wayland the GL adapter is then rejected outright as
3744    // surface-incompatible, which kills the OpenGL fallback entirely.
3745    let mut desc = match display {
3746        Some(handle) => wgpu::InstanceDescriptor::new_with_display_handle(Box::new(handle)),
3747        None => wgpu::InstanceDescriptor::new_without_display_handle(),
3748    };
3749    if cfg!(target_arch = "wasm32") {
3750        desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
3751    }
3752    #[cfg(not(target_arch = "wasm32"))]
3753    if let Ok(spec) = std::env::var("REPOSE_WGPU_BACKENDS")
3754        && let Some(bits) = parse_backends(&spec)
3755    {
3756        log::info!("REPOSE_WGPU_BACKENDS={spec} -> {bits:?}");
3757        desc.backends = bits;
3758    }
3759
3760    let backends = desc.backends;
3761    let instance = if cfg!(target_arch = "wasm32") {
3762        wgpu::util::new_instance_with_webgpu_detection(desc).await
3763    } else {
3764        wgpu::Instance::new(desc)
3765    };
3766    (instance, backends)
3767}
3768
3769impl WgpuSurfaceBackend {
3770    #[cfg(feature = "winit-surface")]
3771    pub async fn new_async(
3772        window: Arc<winit::window::Window>,
3773    ) -> anyhow::Result<WgpuSurfaceBackend> {
3774        Self::new_async_with_options(window, None, 4, PresentModePref::Auto).await
3775    }
3776
3777    /// Create a windowed surface backend, honoring the requested MSAA sample
3778    /// count (falling back to the largest supported count <= `msaa_samples`).
3779    #[cfg(feature = "winit-surface")]
3780    pub async fn new_async_with_msaa(
3781        window: Arc<winit::window::Window>,
3782        msaa_samples: u32,
3783    ) -> anyhow::Result<WgpuSurfaceBackend> {
3784        Self::new_async_with_options(window, None, msaa_samples, PresentModePref::Auto).await
3785    }
3786
3787    /// Create a windowed surface backend, honoring the requested MSAA sample
3788    /// count and present-mode preference.
3789    ///
3790    /// `display` must be the event loop's owned display handle on native
3791    /// platforms, otherwise the GL backend cannot present (see
3792    /// [`surface_instance`]).
3793    #[cfg(feature = "winit-surface")]
3794    pub async fn new_async_with_options(
3795        window: Arc<winit::window::Window>,
3796        display: Option<winit::event_loop::OwnedDisplayHandle>,
3797        msaa_samples: u32,
3798        present_mode: PresentModePref,
3799    ) -> anyhow::Result<WgpuSurfaceBackend> {
3800        let (instance, backends) = surface_instance(display).await;
3801
3802        let surface = instance.create_surface(window.clone())?;
3803
3804        let adapter = instance
3805            .request_adapter(&wgpu::RequestAdapterOptions {
3806                power_preference: wgpu::PowerPreference::HighPerformance,
3807                compatible_surface: Some(&surface),
3808                force_fallback_adapter: false,
3809                apply_limit_buckets: false,
3810            })
3811            .await
3812            .map_err(|e| {
3813                anyhow::anyhow!("no adapter for this window (enabled backends {backends:?}): {e:?}")
3814            })?;
3815        log::info!(
3816            "wgpu adapter: {} backend={:?} driver={:?} type={:?}",
3817            adapter.get_info().name,
3818            adapter.get_info().backend,
3819            adapter.get_info().driver,
3820            adapter.get_info().device_type,
3821        );
3822
3823        let limits = adapter.limits();
3824
3825        let features = {
3826            let available = adapter.features();
3827            let mut features = wgpu::Features::empty();
3828            if available.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) {
3829                features |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
3830            }
3831            #[cfg(target_os = "linux")]
3832            {
3833                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
3834                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
3835                }
3836                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
3837                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
3838                }
3839            }
3840            features
3841        };
3842
3843        let (device, queue) = adapter
3844            .request_device(&wgpu::DeviceDescriptor {
3845                label: Some("repose-rs device"),
3846                required_features: features,
3847                required_limits: limits,
3848                experimental_features: wgpu::ExperimentalFeatures::disabled(),
3849                memory_hints: wgpu::MemoryHints::default(),
3850                trace: wgpu::Trace::Off,
3851            })
3852            .await
3853            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
3854
3855        let device_lost = Arc::new(Mutex::new(None));
3856        {
3857            let device_lost = device_lost.clone();
3858            device.set_device_lost_callback(move |reason, message| {
3859                log::error!("wgpu device lost ({reason:?}): {message}");
3860                if let Ok(mut slot) = device_lost.lock() {
3861                    *slot = Some((reason, message));
3862                }
3863            });
3864        }
3865
3866        let size = window.inner_size();
3867
3868        let caps = surface.get_capabilities(&adapter);
3869
3870        let Some(format) = caps.formats.first().copied() else {
3871            anyhow::bail!(
3872                "surface exposes no presentable formats (adapter {})",
3873                adapter.get_info().name
3874            );
3875        };
3876
3877        let (format, view_format) = if cfg!(target_arch = "wasm32")
3878            && adapter
3879                .get_downlevel_capabilities()
3880                .flags
3881                .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS)
3882        {
3883            let non_srgb = caps
3884                .formats
3885                .iter()
3886                .copied()
3887                .find(|f| !f.is_srgb())
3888                .unwrap_or(format);
3889            (non_srgb, Some(non_srgb.add_srgb_suffix()))
3890        } else if cfg!(target_arch = "wasm32") {
3891            let fmt = caps
3892                .formats
3893                .iter()
3894                .copied()
3895                .find(|f| f.is_srgb())
3896                .unwrap_or(format);
3897            (fmt, None)
3898        } else {
3899            let fmt = caps
3900                .formats
3901                .iter()
3902                .copied()
3903                .find(|f| f.is_srgb())
3904                .unwrap_or(format);
3905            (fmt, None)
3906        };
3907
3908        let present_mode = pick_present_mode(&caps, present_mode);
3909        let alpha_mode = *caps
3910            .alpha_modes
3911            .first()
3912            .ok_or_else(|| anyhow::anyhow!("surface exposes no alpha modes"))?;
3913
3914        let render_format = view_format.unwrap_or(format);
3915        let msaa_samples = pick_surface_msaa(&adapter, render_format, msaa_samples);
3916        let mut renderer = WgpuSceneRenderer::try_from_device_with_adapter(
3917            &adapter,
3918            device,
3919            queue,
3920            render_format,
3921            msaa_samples,
3922        )?
3923        .with_surface_format(format)?;
3924        let (fw, fh) = clamp_output_size(
3925            size.width,
3926            size.height,
3927            renderer.device.limits().max_texture_dimension_2d,
3928        );
3929        renderer.resize(fw, fh);
3930
3931        let view_formats = view_format.into_iter().collect::<Vec<_>>();
3932
3933        // `COPY_SRC` lets an isolated blend snapshot the backdrop straight from
3934        // the swapchain when there is no multisample resolve target to copy
3935        // from. Canvas textures may not be storage-bound, but copying from them
3936        // is allowed. Not every backend grants it - GL surfaces are
3937        // render-target only - and the snapshot path already skips a source
3938        // without `COPY_SRC`, so request the intersection rather than making
3939        // `configure` fail and taking down every backend that lacks the usage.
3940        let wanted_usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
3941        let surface_usage = wanted_usage & caps.usages;
3942        if surface_usage != wanted_usage {
3943            log::warn!(
3944                "surface {format:?} cannot be used as {:?}; swapchain-sourced blend snapshots \
3945                 are skipped on this backend",
3946                wanted_usage - surface_usage
3947            );
3948        }
3949        let config = wgpu::SurfaceConfiguration {
3950            usage: surface_usage,
3951            format,
3952            width: fw.max(1),
3953            height: fh.max(1),
3954            present_mode,
3955            alpha_mode,
3956            color_space: wgpu::SurfaceColorSpace::Auto,
3957            view_formats,
3958            desired_maximum_frame_latency: 1,
3959        };
3960        surface.configure(&renderer.device, &config);
3961
3962        Ok(WgpuSurfaceBackend {
3963            #[cfg(feature = "winit-surface")]
3964            instance: Some(instance),
3965            #[cfg(feature = "winit-surface")]
3966            window: Some(window),
3967            surface: Some(surface),
3968            surface_config: Some(config),
3969            pending_reconfigure: false,
3970            renderer,
3971            device_lost,
3972        })
3973    }
3974
3975    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3976    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
3977        pollster::block_on(Self::new_async(window))
3978    }
3979
3980    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3981    pub fn new_with_msaa(
3982        window: Arc<winit::window::Window>,
3983        msaa_samples: u32,
3984    ) -> anyhow::Result<WgpuSurfaceBackend> {
3985        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
3986    }
3987
3988    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3989    pub fn new_with_options(
3990        window: Arc<winit::window::Window>,
3991        display: Option<winit::event_loop::OwnedDisplayHandle>,
3992        msaa_samples: u32,
3993        present_mode: PresentModePref,
3994    ) -> anyhow::Result<WgpuSurfaceBackend> {
3995        pollster::block_on(Self::new_async_with_options(
3996            window,
3997            display,
3998            msaa_samples,
3999            present_mode,
4000        ))
4001    }
4002
4003    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4004    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4005        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
4006    }
4007
4008    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4009    pub fn new_with_msaa(
4010        _window: Arc<winit::window::Window>,
4011        _msaa_samples: u32,
4012    ) -> anyhow::Result<WgpuSurfaceBackend> {
4013        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
4014    }
4015
4016    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4017    pub fn new_with_options(
4018        _window: Arc<winit::window::Window>,
4019        _display: Option<winit::event_loop::OwnedDisplayHandle>,
4020        _msaa_samples: u32,
4021        _present_mode: PresentModePref,
4022    ) -> anyhow::Result<WgpuSurfaceBackend> {
4023        anyhow::bail!(
4024            "Use WgpuSurfaceBackend::new_async_with_options(window, display, msaa, mode).await on wasm32"
4025        )
4026    }
4027
4028    /// Reason the device was lost, consumed once. `None` while healthy and
4029    /// after an ordinary `Destroyed`, which is what dropping this backend
4030    /// produces; runners use this to tell a crash/reset apart from teardown
4031    /// and rebuild the backend.
4032    pub fn take_device_lost(&self) -> Option<String> {
4033        let (reason, message) = self.device_lost.lock().ok()?.take()?;
4034        (reason != wgpu::DeviceLostReason::Destroyed).then_some(message)
4035    }
4036}
4037
4038/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
4039/// Fifo-first selection when the preferred mode is unavailable.
4040#[cfg(feature = "winit-surface")]
4041fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
4042    let auto = || {
4043        caps.present_modes
4044            .iter()
4045            .copied()
4046            .find(|m| *m == wgpu::PresentMode::Fifo)
4047            .or_else(|| {
4048                caps.present_modes
4049                    .iter()
4050                    .copied()
4051                    .find(|m| *m == wgpu::PresentMode::Mailbox)
4052            })
4053            .unwrap_or(wgpu::PresentMode::Immediate)
4054    };
4055    match pref {
4056        PresentModePref::Auto => auto(),
4057        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
4058            wgpu::PresentMode::Fifo
4059        }
4060        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
4061            wgpu::PresentMode::Mailbox
4062        }
4063        PresentModePref::Immediate
4064            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
4065        {
4066            wgpu::PresentMode::Immediate
4067        }
4068        _ => auto(),
4069    }
4070}
4071
4072/// `wgpu` only accepts these sample counts; anything else (3, 5, 17, 0) is
4073/// a hard validation error at pipeline creation.
4074const VALID_SAMPLE_COUNTS: [u32; 5] = [1, 2, 4, 8, 16];
4075
4076fn validate_sample_count(label: &str, samples: u32) -> anyhow::Result<()> {
4077    if !VALID_SAMPLE_COUNTS.contains(&samples) {
4078        anyhow::bail!(
4079            "{label} must be one of {VALID_SAMPLE_COUNTS:?} (wgpu-supported sample counts), got {samples}"
4080        );
4081    }
4082    Ok(())
4083}
4084
4085/// Every UI pipeline writes `output_format` with premultiplied-alpha
4086/// blending, so those two properties have to hold before any pipeline is
4087/// built. Multisample resolve is only required when more than one sample is
4088/// actually in use: a format that is renderable and blendable but cannot
4089/// resolve is fine at x1, which is what [`pick_surface_msaa`] falls back to.
4090fn validate_render_target_format(
4091    adapter: &wgpu::Adapter,
4092    output_format: wgpu::TextureFormat,
4093    samples: u32,
4094) -> anyhow::Result<()> {
4095    let features = adapter.get_texture_format_features(output_format);
4096    let flags = features.flags;
4097    for (needed, what) in [
4098        (wgpu::TextureUsages::RENDER_ATTACHMENT, "render attachment"),
4099        (wgpu::TextureUsages::TEXTURE_BINDING, "texture binding"),
4100    ] {
4101        if !features.allowed_usages.contains(needed) {
4102            anyhow::bail!("{output_format:?} cannot be used as a {what}");
4103        }
4104    }
4105    if !flags.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) {
4106        anyhow::bail!("{output_format:?} cannot be blended into");
4107    }
4108    if samples > 1 && !flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE) {
4109        anyhow::bail!(
4110            "{output_format:?} cannot resolve the requested {samples}x multisampled attachment"
4111        );
4112    }
4113    // `set_working_space` renders into Rgba16Float and resolves into it.
4114    let ws = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4115    if !ws
4116        .allowed_usages
4117        .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4118        || !ws
4119            .allowed_usages
4120            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4121    {
4122        anyhow::bail!(
4123            "{:?} cannot be used for linear working-space rendering",
4124            wgpu::TextureFormat::Rgba16Float
4125        );
4126    }
4127    Ok(())
4128}
4129
4130/// Pick the MSAA sample count for the surface pass, honoring `requested` and
4131/// falling back to the largest supported count <= it.
4132pub fn pick_surface_msaa(
4133    adapter: &wgpu::Adapter,
4134    format: wgpu::TextureFormat,
4135    requested: u32,
4136) -> u32 {
4137    pick_surface_msaa_for_mode(adapter, format, requested, false)
4138}
4139
4140pub fn pick_surface_msaa_for_mode(
4141    adapter: &wgpu::Adapter,
4142    format: wgpu::TextureFormat,
4143    requested: u32,
4144    working_space: bool,
4145) -> u32 {
4146    let requested = requested.max(1);
4147    let color_feat = adapter.get_texture_format_features(format);
4148    let working_space_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4149    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
4150    let supported = |n: u32| {
4151        color_feat
4152            .allowed_usages
4153            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4154            && color_feat.flags.sample_count_supported(n)
4155            && (!working_space
4156                || (working_space_feat
4157                    .allowed_usages
4158                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4159                    && working_space_feat.flags.sample_count_supported(n)
4160                    && (n == 1
4161                        || working_space_feat
4162                            .flags
4163                            .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))))
4164            && (n == 1
4165                || color_feat
4166                    .flags
4167                    .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))
4168            && depth_feat
4169                .allowed_usages
4170                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4171            && depth_feat.flags.sample_count_supported(n)
4172    };
4173    let mut candidates = vec![requested];
4174    for n in [8, 4, 2, 1] {
4175        if n < requested {
4176            candidates.push(n);
4177        }
4178    }
4179    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
4180    if chosen != requested {
4181        log::info!("requested MSAA x{requested}, using x{chosen}");
4182    }
4183    chosen
4184}
4185
4186impl WgpuSceneRenderer {
4187    // Image API
4188
4189    pub fn set_image_from_bytes(
4190        &mut self,
4191        handle: u64,
4192        data: &[u8],
4193        srgb: bool,
4194    ) -> anyhow::Result<()> {
4195        validate_image_handle(handle)?;
4196        let max_dimension = self.device.limits().max_texture_dimension_2d;
4197        let (w, h, rgba) = decode_image_rgba8(data, max_dimension)?;
4198        self.set_image_rgba8(handle, w, h, &rgba, srgb)?;
4199        // Encoded uploads have no size at the call site, so publish it here for
4200        // layout - only once the pixels are actually on the GPU.
4201        repose_core::set_image_intrinsic_size(handle, w, h);
4202        Ok(())
4203    }
4204
4205    pub fn set_image_rgba8(
4206        &mut self,
4207        handle: u64,
4208        w: u32,
4209        h: u32,
4210        rgba: &[u8],
4211        srgb: bool,
4212    ) -> anyhow::Result<()> {
4213        validate_image_handle(handle)?;
4214        validate_texture_dimensions(&self.device, w, h)?;
4215        let expected = checked_image_bytes_usize(w, h, 4)?;
4216        if rgba.len() < expected {
4217            return Err(anyhow::anyhow!(
4218                "RGBA buffer too small: {} < {}",
4219                rgba.len(),
4220                expected
4221            ));
4222        }
4223
4224        let format = if srgb {
4225            wgpu::TextureFormat::Rgba8UnormSrgb
4226        } else {
4227            wgpu::TextureFormat::Rgba8Unorm
4228        };
4229
4230        let needs_recreate = match self.images.get(&handle) {
4231            Some(ImageTex::Rgba {
4232                w: cw,
4233                h: ch,
4234                format: cf,
4235                ..
4236            }) => *cw != w || *ch != h || *cf != format,
4237            _ => true,
4238        };
4239
4240        if needs_recreate {
4241            self.remove_image(handle);
4242
4243            let (tex, binds) = self.create_rgba_tex(w, h, format);
4244            let bytes = checked_image_bytes(w, h, 4)?;
4245            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4246
4247            self.images.insert(
4248                handle,
4249                ImageTex::Rgba {
4250                    tex,
4251                    binds,
4252                    w,
4253                    h,
4254                    format,
4255                    last_used_frame: self.frame_index,
4256                    bytes,
4257                },
4258            );
4259        }
4260
4261        if let Some(old) = self.retained.remove(&handle) {
4262            self.retained_bytes_total = self
4263                .retained_bytes_total
4264                .saturating_sub(old.rgba.len() as u64);
4265        }
4266        self.retained_bytes_total = self.retained_bytes_total.saturating_add(expected as u64);
4267        self.retained.insert(
4268            handle,
4269            RetainedImage {
4270                w,
4271                h,
4272                format,
4273                rgba: rgba[..expected].to_vec(),
4274                last_used_frame: self.frame_index,
4275            },
4276        );
4277        self.evict_retained_excess();
4278
4279        let tex = match self.images.get(&handle) {
4280            Some(ImageTex::Rgba { tex, .. }) => tex,
4281            _ => unreachable!(),
4282        };
4283
4284        self.queue.write_texture(
4285            wgpu::TexelCopyTextureInfo {
4286                texture: tex,
4287                mip_level: 0,
4288                origin: wgpu::Origin3d::ZERO,
4289                aspect: wgpu::TextureAspect::All,
4290            },
4291            &rgba[..expected],
4292            wgpu::TexelCopyBufferLayout {
4293                offset: 0,
4294                bytes_per_row: Some(4 * w),
4295                rows_per_image: Some(h),
4296            },
4297            wgpu::Extent3d {
4298                width: w,
4299                height: h,
4300                depth_or_array_layers: 1,
4301            },
4302        );
4303
4304        // Ensure budget limits
4305        self.evict_budget_excess();
4306
4307        Ok(())
4308    }
4309
4310    /// Create (but do not populate) the GPU texture, view and bind group for an
4311    /// RGBA image. Pixels are written separately via `write_texture`.
4312    fn create_image_binds(
4313        &self,
4314        view: &wgpu::TextureView,
4315        label: &str,
4316        sampler: Option<&wgpu::Sampler>,
4317    ) -> ImageBinds {
4318        let make = |name: &str, sampler: &wgpu::Sampler| {
4319            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4320                label: Some(name),
4321                layout: &self.image_bind_layout_rgba,
4322                entries: &[
4323                    wgpu::BindGroupEntry {
4324                        binding: 0,
4325                        resource: wgpu::BindingResource::TextureView(view),
4326                    },
4327                    wgpu::BindGroupEntry {
4328                        binding: 1,
4329                        resource: wgpu::BindingResource::Sampler(sampler),
4330                    },
4331                ],
4332            })
4333        };
4334        match sampler {
4335            Some(sampler) => {
4336                let bind = make(label, sampler);
4337                ImageBinds {
4338                    linear: make(label, sampler),
4339                    nearest: bind.clone(),
4340                    tile_linear: bind.clone(),
4341                    tile_nearest: bind,
4342                }
4343            }
4344            None => ImageBinds {
4345                linear: make(label, &self.image_sampler),
4346                nearest: make(label, &self.image_sampler_nearest),
4347                tile_linear: make(label, &self.image_sampler_repeat),
4348                tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4349            },
4350        }
4351    }
4352
4353    fn create_nv12_binds(
4354        &self,
4355        view_y: &wgpu::TextureView,
4356        view_uv: &wgpu::TextureView,
4357        yuv_buf: &wgpu::Buffer,
4358        label: &str,
4359    ) -> ImageBinds {
4360        let make = |name: &str, sampler: &wgpu::Sampler| {
4361            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4362                label: Some(name),
4363                layout: &self.image_bind_layout_nv12,
4364                entries: &[
4365                    wgpu::BindGroupEntry {
4366                        binding: 0,
4367                        resource: wgpu::BindingResource::TextureView(view_y),
4368                    },
4369                    wgpu::BindGroupEntry {
4370                        binding: 1,
4371                        resource: wgpu::BindingResource::TextureView(view_uv),
4372                    },
4373                    wgpu::BindGroupEntry {
4374                        binding: 2,
4375                        resource: wgpu::BindingResource::Sampler(sampler),
4376                    },
4377                    wgpu::BindGroupEntry {
4378                        binding: 3,
4379                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4380                            buffer: yuv_buf,
4381                            offset: 0,
4382                            size: None,
4383                        }),
4384                    },
4385                ],
4386            })
4387        };
4388        ImageBinds {
4389            linear: make(label, &self.image_sampler),
4390            nearest: make(label, &self.image_sampler_nearest),
4391            tile_linear: make(label, &self.image_sampler_repeat),
4392            tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4393        }
4394    }
4395
4396    fn create_rgba_tex(
4397        &self,
4398        w: u32,
4399        h: u32,
4400        format: wgpu::TextureFormat,
4401    ) -> (wgpu::Texture, ImageBinds) {
4402        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
4403            label: Some("user image rgba"),
4404            size: wgpu::Extent3d {
4405                width: w,
4406                height: h,
4407                depth_or_array_layers: 1,
4408            },
4409            mip_level_count: 1,
4410            sample_count: 1,
4411            dimension: wgpu::TextureDimension::D2,
4412            format,
4413            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4414            view_formats: &[],
4415        });
4416        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
4417        let binds = self.create_image_binds(&view, "image bind rgba", None);
4418        (tex, binds)
4419    }
4420
4421    /// Register an externally-created `wgpu::TextureView` as an image (zero-copy).
4422    fn validate_native_texture_view(
4423        &self,
4424        view: &wgpu::TextureView,
4425        width: u32,
4426        height: u32,
4427    ) -> anyhow::Result<()> {
4428        if width == 0 || height == 0 {
4429            anyhow::bail!("native texture dimensions must be non-zero");
4430        }
4431        let texture = view.texture();
4432        if texture.sample_count() != 1
4433            || texture.dimension() != wgpu::TextureDimension::D2
4434            || texture.depth_or_array_layers() != 1
4435            || texture.mip_level_count() == 0
4436        {
4437            anyhow::bail!("native texture must be a single-sample 2D texture with mip levels");
4438        }
4439        if width > self.device.limits().max_texture_dimension_2d
4440            || height > self.device.limits().max_texture_dimension_2d
4441        {
4442            anyhow::bail!("native texture dimensions exceed the device limit");
4443        }
4444        if !texture
4445            .usage()
4446            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4447        {
4448            anyhow::bail!("native texture lacks TEXTURE_BINDING usage");
4449        }
4450        if !native_format_supported(texture.format(), self.device.features()) {
4451            anyhow::bail!(
4452                "native texture format {:?} is not a filterable alpha color format",
4453                texture.format()
4454            );
4455        }
4456        if texture.width() < width || texture.height() < height {
4457            anyhow::bail!(
4458                "native texture is {}x{}, smaller than requested {width}x{height}",
4459                texture.width(),
4460                texture.height()
4461            );
4462        }
4463        Ok(())
4464    }
4465
4466    pub fn register_native_texture(
4467        &mut self,
4468        view: &wgpu::TextureView,
4469        width: u32,
4470        height: u32,
4471    ) -> u64 {
4472        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4473            log::warn!("register_native_texture: {error:#}");
4474            return 0;
4475        }
4476        let handle = self.next_image_handle;
4477        self.next_image_handle += 1;
4478        let binds = self.create_image_binds(view, "user native image", None);
4479        self.images.insert(
4480            handle,
4481            ImageTex::User {
4482                binds,
4483                w: width,
4484                h: height,
4485                last_used_frame: self.frame_index,
4486                bytes: 0,
4487            },
4488        );
4489        handle
4490    }
4491
4492    /// Like `register_native_texture` but with custom sampler descriptor.
4493    pub fn register_native_texture_with_sampler(
4494        &mut self,
4495        view: &wgpu::TextureView,
4496        sampler_desc: wgpu::SamplerDescriptor<'_>,
4497        width: u32,
4498        height: u32,
4499    ) -> u64 {
4500        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4501            log::warn!("register_native_texture: {error:#}");
4502            return 0;
4503        }
4504        if let Err(error) = validate_sampler_descriptor(&self.device, &sampler_desc) {
4505            log::warn!("register_native_texture: {error:#}");
4506            return 0;
4507        }
4508        let handle = self.next_image_handle;
4509        self.next_image_handle += 1;
4510        let sampler = self.device.create_sampler(&sampler_desc);
4511        let binds = self.create_image_binds(view, "user native image sampleropts", Some(&sampler));
4512        self.images.insert(
4513            handle,
4514            ImageTex::User {
4515                binds,
4516                w: width,
4517                h: height,
4518                last_used_frame: self.frame_index,
4519                bytes: 0,
4520            },
4521        );
4522        handle
4523    }
4524
4525    /// Update an existing native texture handle with a new view (reuse handle).
4526    pub fn update_native_texture(&mut self, handle: u64, view: &wgpu::TextureView) {
4527        if handle == 0 {
4528            log::warn!("update_native_texture: reserved handle");
4529            return;
4530        }
4531        let Some((w, h)) = self.images.get(&handle).and_then(|entry| match entry {
4532            ImageTex::User { w, h, .. } => Some((*w, *h)),
4533            _ => None,
4534        }) else {
4535            log::warn!("update_native_texture: handle {handle} is not a native image");
4536            return;
4537        };
4538        if let Err(error) = self.validate_native_texture_view(view, w, h) {
4539            log::warn!("update_native_texture: {error:#}");
4540            return;
4541        }
4542        let binds = self.create_image_binds(view, "user native image update", None);
4543        if let Some(entry) = self.images.get_mut(&handle) {
4544            *entry = ImageTex::User {
4545                binds,
4546                w,
4547                h,
4548                last_used_frame: self.frame_index,
4549                bytes: 0,
4550            };
4551        }
4552    }
4553
4554    pub fn set_image_nv12(
4555        &mut self,
4556        handle: u64,
4557        w: u32,
4558        h: u32,
4559        y: &[u8],
4560        uv: &[u8],
4561        color_info: ColorInfo,
4562    ) -> anyhow::Result<()> {
4563        validate_image_handle(handle)?;
4564        validate_texture_dimensions(&self.device, w, h)?;
4565        if self
4566            .images
4567            .get(&handle)
4568            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4569        {
4570            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4571        }
4572        let y_expected = checked_image_bytes_usize(w, h, 1)?;
4573        let uv_w = w.div_ceil(2);
4574        let uv_h = h.div_ceil(2);
4575        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 2)?;
4576
4577        if y.len() < y_expected {
4578            return Err(anyhow::anyhow!("Y plane too small"));
4579        }
4580        if uv.len() < uv_expected {
4581            return Err(anyhow::anyhow!("UV plane too small"));
4582        }
4583
4584        let needs_recreate = match self.images.get(&handle) {
4585            Some(ImageTex::Nv12 {
4586                w: ww,
4587                h: hh,
4588                pixel_format,
4589                ..
4590            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::Nv12,
4591            _ => true,
4592        };
4593
4594        // Compute the YUV->RGB transform on the CPU.
4595        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::Nv12);
4596
4597        if needs_recreate {
4598            self.remove_image(handle);
4599
4600            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4601                label: Some("nv12 Y"),
4602                size: wgpu::Extent3d {
4603                    width: w,
4604                    height: h,
4605                    depth_or_array_layers: 1,
4606                },
4607                mip_level_count: 1,
4608                sample_count: 1,
4609                dimension: wgpu::TextureDimension::D2,
4610                format: wgpu::TextureFormat::R8Unorm,
4611                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4612                view_formats: &[],
4613            });
4614            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4615
4616            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4617                label: Some("nv12 UV"),
4618                size: wgpu::Extent3d {
4619                    width: uv_w,
4620                    height: uv_h,
4621                    depth_or_array_layers: 1,
4622                },
4623                mip_level_count: 1,
4624                sample_count: 1,
4625                dimension: wgpu::TextureDimension::D2,
4626                format: wgpu::TextureFormat::Rg8Unorm,
4627                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4628                view_formats: &[],
4629            });
4630            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4631
4632            // Create a uniform buffer for the YUV transform (per-image).
4633            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4634                label: Some("nv12 yuv transform"),
4635                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4636                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4637                mapped_at_creation: false,
4638            });
4639
4640            // Write initial transform.
4641            self.queue
4642                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4643
4644            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "nv12 bind");
4645
4646            let bytes = checked_image_bytes(w, h, 1)?
4647                .checked_add(checked_image_bytes(uv_w, uv_h, 2)?)
4648                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4649                .ok_or_else(|| anyhow::anyhow!("NV12 image byte size overflow"))?;
4650            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4651
4652            self.images.insert(
4653                handle,
4654                ImageTex::Nv12 {
4655                    tex_y,
4656                    tex_uv,
4657                    binds,
4658                    yuv_buf,
4659                    w,
4660                    h,
4661                    pixel_format: PixelFormat::Nv12,
4662                    fourcc: Some(NV12_FOURCC),
4663                    color_info,
4664                    external: false,
4665                    last_used_frame: self.frame_index,
4666                    bytes,
4667                },
4668            );
4669        } else {
4670            // Re-use existing textures; just update the YUV transform if needed.
4671            if let Some(ImageTex::Nv12 {
4672                yuv_buf,
4673                color_info: stored_color,
4674                last_used_frame,
4675                ..
4676            }) = self.images.get_mut(&handle)
4677            {
4678                *stored_color = color_info;
4679                *last_used_frame = self.frame_index;
4680                self.queue
4681                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4682            }
4683        }
4684
4685        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4686            Some(ImageTex::Nv12 {
4687                tex_y,
4688                tex_uv,
4689                binds,
4690                ..
4691            }) => (tex_y, tex_uv, binds),
4692            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
4693        };
4694
4695        self.queue.write_texture(
4696            wgpu::TexelCopyTextureInfo {
4697                texture: tex_y,
4698                mip_level: 0,
4699                origin: wgpu::Origin3d::ZERO,
4700                aspect: wgpu::TextureAspect::All,
4701            },
4702            &y[..y_expected],
4703            wgpu::TexelCopyBufferLayout {
4704                offset: 0,
4705                bytes_per_row: Some(w),
4706                rows_per_image: Some(h),
4707            },
4708            wgpu::Extent3d {
4709                width: w,
4710                height: h,
4711                depth_or_array_layers: 1,
4712            },
4713        );
4714
4715        self.queue.write_texture(
4716            wgpu::TexelCopyTextureInfo {
4717                texture: tex_uv,
4718                mip_level: 0,
4719                origin: wgpu::Origin3d::ZERO,
4720                aspect: wgpu::TextureAspect::All,
4721            },
4722            &uv[..uv_expected],
4723            wgpu::TexelCopyBufferLayout {
4724                offset: 0,
4725                bytes_per_row: Some(2 * uv_w),
4726                rows_per_image: Some(uv_h),
4727            },
4728            wgpu::Extent3d {
4729                width: uv_w,
4730                height: uv_h,
4731                depth_or_array_layers: 1,
4732            },
4733        );
4734
4735        self.evict_budget_excess();
4736        Ok(())
4737    }
4738
4739    pub fn set_image_planes(
4740        &mut self,
4741        handle: u64,
4742        w: u32,
4743        h: u32,
4744        pixel_format: PixelFormat,
4745        planes: &[&[u8]],
4746        color_info: ColorInfo,
4747    ) -> anyhow::Result<()> {
4748        validate_image_handle(handle)?;
4749        if planes.len() != pixel_format.num_planes() as usize {
4750            anyhow::bail!(
4751                "{} requires {} planes, got {}",
4752                match pixel_format {
4753                    PixelFormat::Nv12 => "NV12",
4754                    PixelFormat::P010 => "P010",
4755                    PixelFormat::I420 => "I420",
4756                    PixelFormat::I444 => "I444",
4757                    PixelFormat::Rgba => "RGBA",
4758                },
4759                pixel_format.num_planes(),
4760                planes.len()
4761            );
4762        }
4763        match pixel_format {
4764            PixelFormat::Nv12 => {
4765                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4766                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4767                self.set_image_nv12(handle, w, h, y, uv, color_info)
4768            }
4769            PixelFormat::P010 => {
4770                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4771                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4772                self.set_image_p010(handle, w, h, y, uv, color_info)
4773            }
4774            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
4775                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
4776            )),
4777            PixelFormat::Rgba => {
4778                let rgba = planes
4779                    .first()
4780                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
4781                self.set_image_rgba8(handle, w, h, rgba, false)
4782            }
4783        }
4784    }
4785
4786    fn set_image_p010(
4787        &mut self,
4788        handle: u64,
4789        w: u32,
4790        h: u32,
4791        y: &[u8],
4792        uv: &[u8],
4793        color_info: ColorInfo,
4794    ) -> anyhow::Result<()> {
4795        validate_image_handle(handle)?;
4796        validate_texture_dimensions(&self.device, w, h)?;
4797        if self
4798            .images
4799            .get(&handle)
4800            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4801        {
4802            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4803        }
4804        if !self
4805            .device
4806            .features()
4807            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
4808        {
4809            anyhow::bail!("P010 requires TEXTURE_FORMAT_16BIT_NORM");
4810        }
4811        let uv_w = w.div_ceil(2);
4812        let uv_h = h.div_ceil(2);
4813
4814        let y_expected = checked_image_bytes_usize(w, h, 2)?;
4815        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 4)?;
4816
4817        if y.len() < y_expected {
4818            return Err(anyhow::anyhow!("P010 Y plane too small"));
4819        }
4820        if uv.len() < uv_expected {
4821            return Err(anyhow::anyhow!("P010 UV plane too small"));
4822        }
4823
4824        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
4825        // abstracts the storage format so R16Unorm/Rg16Unorm are
4826        // filterable float textures just like R8Unorm/Rg8Unorm).
4827        let needs_recreate = match self.images.get(&handle) {
4828            Some(ImageTex::Nv12 {
4829                w: ww,
4830                h: hh,
4831                pixel_format,
4832                ..
4833            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::P010,
4834            _ => true,
4835        };
4836
4837        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::P010);
4838
4839        if needs_recreate {
4840            self.remove_image(handle);
4841
4842            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4843                label: Some("p010 Y"),
4844                size: wgpu::Extent3d {
4845                    width: w,
4846                    height: h,
4847                    depth_or_array_layers: 1,
4848                },
4849                mip_level_count: 1,
4850                sample_count: 1,
4851                dimension: wgpu::TextureDimension::D2,
4852                format: wgpu::TextureFormat::R16Unorm,
4853                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4854                view_formats: &[],
4855            });
4856            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4857
4858            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4859                label: Some("p010 UV"),
4860                size: wgpu::Extent3d {
4861                    width: uv_w,
4862                    height: uv_h,
4863                    depth_or_array_layers: 1,
4864                },
4865                mip_level_count: 1,
4866                sample_count: 1,
4867                dimension: wgpu::TextureDimension::D2,
4868                format: wgpu::TextureFormat::Rg16Unorm,
4869                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4870                view_formats: &[],
4871            });
4872            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4873
4874            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4875                label: Some("p010 yuv transform"),
4876                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4877                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4878                mapped_at_creation: false,
4879            });
4880            self.queue
4881                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4882
4883            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "p010 bind");
4884
4885            let bytes = checked_image_bytes(w, h, 2)?
4886                .checked_add(checked_image_bytes(uv_w, uv_h, 4)?)
4887                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4888                .ok_or_else(|| anyhow::anyhow!("P010 image byte size overflow"))?;
4889            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4890
4891            self.images.insert(
4892                handle,
4893                ImageTex::Nv12 {
4894                    tex_y,
4895                    tex_uv,
4896                    binds,
4897                    yuv_buf,
4898                    w,
4899                    h,
4900                    pixel_format: PixelFormat::P010,
4901                    fourcc: Some(P010_FOURCC),
4902                    color_info,
4903                    external: false,
4904                    last_used_frame: self.frame_index,
4905                    bytes,
4906                },
4907            );
4908        } else {
4909            if let Some(ImageTex::Nv12 {
4910                yuv_buf,
4911                color_info: stored_color,
4912                last_used_frame,
4913                ..
4914            }) = self.images.get_mut(&handle)
4915            {
4916                *stored_color = color_info;
4917                *last_used_frame = self.frame_index;
4918                self.queue
4919                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4920            }
4921        }
4922
4923        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4924            Some(ImageTex::Nv12 {
4925                tex_y,
4926                tex_uv,
4927                binds,
4928                ..
4929            }) => (tex_y, tex_uv, binds),
4930            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
4931        };
4932
4933        self.queue.write_texture(
4934            wgpu::TexelCopyTextureInfo {
4935                texture: tex_y,
4936                mip_level: 0,
4937                origin: wgpu::Origin3d::ZERO,
4938                aspect: wgpu::TextureAspect::All,
4939            },
4940            &y[..y_expected],
4941            wgpu::TexelCopyBufferLayout {
4942                offset: 0,
4943                bytes_per_row: Some(
4944                    w.checked_mul(2)
4945                        .ok_or_else(|| anyhow::anyhow!("P010 row size overflow"))?,
4946                ),
4947                rows_per_image: Some(h),
4948            },
4949            wgpu::Extent3d {
4950                width: w,
4951                height: h,
4952                depth_or_array_layers: 1,
4953            },
4954        );
4955        self.queue.write_texture(
4956            wgpu::TexelCopyTextureInfo {
4957                texture: tex_uv,
4958                mip_level: 0,
4959                origin: wgpu::Origin3d::ZERO,
4960                aspect: wgpu::TextureAspect::All,
4961            },
4962            &uv[..uv_expected],
4963            wgpu::TexelCopyBufferLayout {
4964                offset: 0,
4965                bytes_per_row: Some(
4966                    uv_w.checked_mul(4)
4967                        .ok_or_else(|| anyhow::anyhow!("P010 UV row size overflow"))?,
4968                ),
4969                rows_per_image: Some(uv_h),
4970            },
4971            wgpu::Extent3d {
4972                width: uv_w,
4973                height: uv_h,
4974                depth_or_array_layers: 1,
4975            },
4976        );
4977
4978        self.evict_budget_excess();
4979        Ok(())
4980    }
4981
4982    #[cfg(target_os = "linux")]
4983    pub fn set_image_dmabuf(
4984        &mut self,
4985        handle: u64,
4986        w: u32,
4987        h: u32,
4988        fds: Vec<std::os::unix::io::OwnedFd>,
4989        modifier: u64,
4990        strides: Vec<u32>,
4991        offsets: Vec<u64>,
4992        color_info: ColorInfo,
4993    ) -> anyhow::Result<()> {
4994        self.set_image_dmabuf_fourcc(
4995            handle,
4996            w,
4997            h,
4998            fds,
4999            NV12_FOURCC,
5000            modifier,
5001            strides,
5002            offsets,
5003            color_info,
5004        )
5005    }
5006
5007    #[cfg(target_os = "linux")]
5008    pub fn set_image_dmabuf_fourcc(
5009        &mut self,
5010        handle: u64,
5011        w: u32,
5012        h: u32,
5013        fds: Vec<std::os::unix::io::OwnedFd>,
5014        fourcc: u32,
5015        modifier: u64,
5016        strides: Vec<u32>,
5017        offsets: Vec<u64>,
5018        color_info: ColorInfo,
5019    ) -> anyhow::Result<()> {
5020        validate_image_handle(handle)?;
5021        checked_image_bytes(w, h, 1)?;
5022        if w == 0
5023            || h == 0
5024            || w > self.device.limits().max_texture_dimension_2d
5025            || h > self.device.limits().max_texture_dimension_2d
5026        {
5027            anyhow::bail!("DMA-BUF dimensions are outside the device texture limit");
5028        }
5029        if modifier == u64::MAX {
5030            anyhow::bail!("DMA-BUF modifier is invalid");
5031        }
5032        if fds.len() != 2 || strides.len() != 2 || offsets.len() != 2 {
5033            anyhow::bail!(
5034                "DMA-BUF import requires two fds, strides, and offsets (got {}, {}, {})",
5035                fds.len(),
5036                strides.len(),
5037                offsets.len()
5038            );
5039        }
5040        let pixel_format = match fourcc {
5041            0x3231_564e | 0x4e56_3132 => PixelFormat::Nv12,
5042            0x3031_3050 | 0x5030_3130 => PixelFormat::P010,
5043            _ => anyhow::bail!("unsupported DMA-BUF fourcc 0x{fourcc:08x}"),
5044        };
5045        if !self
5046            .device
5047            .features()
5048            .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF)
5049        {
5050            anyhow::bail!("DMA-BUF import requires VULKAN_EXTERNAL_MEMORY_DMA_BUF");
5051        }
5052        if pixel_format == PixelFormat::P010
5053            && !self
5054                .device
5055                .features()
5056                .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
5057        {
5058            anyhow::bail!("P010 DMA-BUF requires TEXTURE_FORMAT_16BIT_NORM");
5059        }
5060        let bytes_per_pixel = if pixel_format == PixelFormat::P010 {
5061            2
5062        } else {
5063            1
5064        };
5065        let uv_w = w.div_ceil(2);
5066        let uv_h = h.div_ceil(2);
5067        let y_file_size =
5068            validate_dmabuf_plane(&fds[0], offsets[0], strides[0], w, h, bytes_per_pixel)?;
5069        let uv_file_size = validate_dmabuf_plane(
5070            &fds[1],
5071            offsets[1],
5072            strides[1],
5073            uv_w,
5074            uv_h,
5075            bytes_per_pixel * 2,
5076        )?;
5077        let yuv_raw = make_yuv_transform_raw(color_info, pixel_format);
5078        let (y_format, uv_format) = if pixel_format == PixelFormat::P010 {
5079            (
5080                wgpu::TextureFormat::R16Unorm,
5081                wgpu::TextureFormat::Rg16Unorm,
5082            )
5083        } else {
5084            (wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::Rg8Unorm)
5085        };
5086        let hal_y_desc = wgpu::hal::TextureDescriptor {
5087            label: Some("dmabuf y"),
5088            size: wgpu::Extent3d {
5089                width: w,
5090                height: h,
5091                depth_or_array_layers: 1,
5092            },
5093            mip_level_count: 1,
5094            sample_count: 1,
5095            dimension: wgpu::TextureDimension::D2,
5096            format: y_format,
5097            usage: wgpu::wgt::TextureUses::RESOURCE,
5098            memory_flags: wgpu::hal::MemoryFlags::empty(),
5099            view_formats: vec![],
5100        };
5101        let hal_uv_desc = wgpu::hal::TextureDescriptor {
5102            label: Some("dmabuf uv"),
5103            size: wgpu::Extent3d {
5104                width: uv_w,
5105                height: uv_h,
5106                depth_or_array_layers: 1,
5107            },
5108            mip_level_count: 1,
5109            sample_count: 1,
5110            dimension: wgpu::TextureDimension::D2,
5111            format: uv_format,
5112            usage: wgpu::wgt::TextureUses::RESOURCE,
5113            memory_flags: wgpu::hal::MemoryFlags::empty(),
5114            view_formats: vec![],
5115        };
5116        let wgpu_y_desc = wgpu::TextureDescriptor {
5117            label: Some("dmabuf y"),
5118            size: hal_y_desc.size,
5119            mip_level_count: 1,
5120            sample_count: 1,
5121            dimension: wgpu::TextureDimension::D2,
5122            format: y_format,
5123            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5124            view_formats: &[],
5125        };
5126        let wgpu_uv_desc = wgpu::TextureDescriptor {
5127            label: Some("dmabuf uv"),
5128            size: hal_uv_desc.size,
5129            mip_level_count: 1,
5130            sample_count: 1,
5131            dimension: wgpu::TextureDimension::D2,
5132            format: uv_format,
5133            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5134            view_formats: &[],
5135        };
5136
5137        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
5138            let hal_guard = self
5139                .device
5140                .as_hal::<wgpu::hal::vulkan::Api>()
5141                .ok_or_else(|| anyhow::anyhow!("device is not Vulkan"))?;
5142            let mut fds = fds.into_iter();
5143            let y_fd = fds.next().expect("validated fd count");
5144            let uv_fd = fds.next().expect("validated fd count");
5145            let y_texture = hal_guard.texture_from_dmabuf_fd(
5146                y_fd,
5147                &hal_y_desc,
5148                modifier,
5149                strides[0] as u64,
5150                offsets[0],
5151            );
5152            let y_texture =
5153                y_texture.map_err(|error| anyhow::anyhow!("import Y DMA-BUF: {error:?}"))?;
5154            let uv_texture = hal_guard.texture_from_dmabuf_fd(
5155                uv_fd,
5156                &hal_uv_desc,
5157                modifier,
5158                strides[1] as u64,
5159                offsets[1],
5160            );
5161            let uv_texture =
5162                uv_texture.map_err(|error| anyhow::anyhow!("import UV DMA-BUF: {error:?}"))?;
5163            drop(hal_guard);
5164            let tex_y = self
5165                .device
5166                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5167                    y_texture,
5168                    &wgpu_y_desc,
5169                    wgpu::wgt::TextureUses::UNINITIALIZED,
5170                );
5171            let tex_uv = self
5172                .device
5173                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5174                    uv_texture,
5175                    &wgpu_uv_desc,
5176                    wgpu::wgt::TextureUses::UNINITIALIZED,
5177                );
5178            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
5179            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5180            (tex_y, view_y, tex_uv, view_uv)
5181        };
5182
5183        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5184            label: Some("dmabuf yuv transform"),
5185            size: std::mem::size_of::<YuvTransformRaw>() as u64,
5186            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5187            mapped_at_creation: false,
5188        });
5189        self.queue
5190            .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5191        let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "dmabuf yuv bind");
5192        let bytes = y_file_size
5193            .checked_add(uv_file_size)
5194            .and_then(|value| value.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5195            .ok_or_else(|| anyhow::anyhow!("DMA-BUF byte size overflow"))?;
5196        self.evict_budget_excess();
5197        let replaced_bytes = self
5198            .images
5199            .get(&handle)
5200            .map(|image| match image {
5201                ImageTex::Rgba { bytes, .. }
5202                | ImageTex::Nv12 { bytes, .. }
5203                | ImageTex::User { bytes, .. } => *bytes,
5204            })
5205            .unwrap_or(0);
5206        let projected = self
5207            .image_bytes_total
5208            .saturating_sub(replaced_bytes)
5209            .checked_add(bytes)
5210            .ok_or_else(|| anyhow::anyhow!("DMA-BUF image budget overflow"))?;
5211        if projected > self.image_budget_bytes {
5212            anyhow::bail!("DMA-BUF image budget exceeded");
5213        }
5214        self.remove_image(handle);
5215        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5216        self.images.insert(
5217            handle,
5218            ImageTex::Nv12 {
5219                tex_y,
5220                tex_uv,
5221                binds,
5222                yuv_buf,
5223                w,
5224                h,
5225                pixel_format,
5226                fourcc: Some(canonical_fourcc(pixel_format)),
5227                color_info,
5228                external: true,
5229                last_used_frame: self.frame_index,
5230                bytes,
5231            },
5232        );
5233        self.evict_budget_excess();
5234        Ok(())
5235    }
5236
5237    pub fn image_fourcc(&self, handle: u64) -> Option<u32> {
5238        match self.images.get(&handle) {
5239            Some(ImageTex::Nv12 { fourcc, .. }) => *fourcc,
5240            _ => None,
5241        }
5242    }
5243
5244    pub fn remove_image(&mut self, handle: u64) {
5245        if let Some(img) = self.images.remove(&handle) {
5246            let b = match &img {
5247                ImageTex::Rgba { bytes, .. } => *bytes,
5248                ImageTex::Nv12 { bytes, .. } => *bytes,
5249                ImageTex::User { bytes, .. } => *bytes,
5250            };
5251            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5252        }
5253        if let Some(retained) = self.retained.remove(&handle) {
5254            self.retained_bytes_total = self
5255                .retained_bytes_total
5256                .saturating_sub(retained.rgba.len() as u64);
5257        }
5258    }
5259
5260    fn evict_image_gpu(&mut self, handle: u64) -> u64 {
5261        let Some(img) = self.images.remove(&handle) else {
5262            return 0;
5263        };
5264        let b = match &img {
5265            ImageTex::Rgba { bytes, .. } => *bytes,
5266            ImageTex::Nv12 { bytes, .. } => *bytes,
5267            ImageTex::User { bytes, .. } => *bytes,
5268        };
5269        self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5270        b
5271    }
5272
5273    fn revive_retained_image(&mut self, handle: u64) -> anyhow::Result<bool> {
5274        if self.images.contains_key(&handle) {
5275            return Ok(true);
5276        }
5277        let Some(r) = self.retained.get_mut(&handle) else {
5278            return Ok(false);
5279        };
5280        r.last_used_frame = self.frame_index;
5281        let r = r.clone();
5282        let (tex, binds) = self.create_rgba_tex(r.w, r.h, r.format);
5283
5284        self.queue.write_texture(
5285            wgpu::TexelCopyTextureInfo {
5286                texture: &tex,
5287                mip_level: 0,
5288                origin: wgpu::Origin3d::ZERO,
5289                aspect: wgpu::TextureAspect::All,
5290            },
5291            &r.rgba,
5292            wgpu::TexelCopyBufferLayout {
5293                offset: 0,
5294                bytes_per_row: Some(4 * r.w),
5295                rows_per_image: Some(r.h),
5296            },
5297            wgpu::Extent3d {
5298                width: r.w,
5299                height: r.h,
5300                depth_or_array_layers: 1,
5301            },
5302        );
5303
5304        let bytes = checked_image_bytes(r.w, r.h, 4)?;
5305        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5306        self.images.insert(
5307            handle,
5308            ImageTex::Rgba {
5309                tex,
5310                binds,
5311                w: r.w,
5312                h: r.h,
5313                format: r.format,
5314                last_used_frame: self.frame_index,
5315                bytes,
5316            },
5317        );
5318        Ok(true)
5319    }
5320
5321    fn resolve_image_for_draw(&mut self, handle: u64) -> Option<(u32, u32, bool)> {
5322        if let Some(retained) = self.retained.get_mut(&handle) {
5323            retained.last_used_frame = self.frame_index;
5324        }
5325        if let Some(t) = self.images.get_mut(&handle) {
5326            return match t {
5327                ImageTex::Rgba {
5328                    w,
5329                    h,
5330                    last_used_frame,
5331                    ..
5332                } => {
5333                    *last_used_frame = self.frame_index;
5334                    Some((*w, *h, false))
5335                }
5336                ImageTex::User {
5337                    w,
5338                    h,
5339                    last_used_frame,
5340                    ..
5341                } => {
5342                    *last_used_frame = self.frame_index;
5343                    Some((*w, *h, false))
5344                }
5345                ImageTex::Nv12 {
5346                    w,
5347                    h,
5348                    last_used_frame,
5349                    ..
5350                } => {
5351                    *last_used_frame = self.frame_index;
5352                    Some((*w, *h, true))
5353                }
5354            };
5355        }
5356        if self.revive_retained_image(handle).unwrap_or(false)
5357            && let Some(ImageTex::Rgba {
5358                w,
5359                h,
5360                last_used_frame,
5361                ..
5362            }) = self.images.get_mut(&handle)
5363        {
5364            *last_used_frame = self.frame_index;
5365            return Some((*w, *h, false));
5366        }
5367        None
5368    }
5369
5370    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
5371    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
5372        let handle = self.next_image_handle;
5373        self.next_image_handle += 1;
5374        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
5375            log::error!("Failed to register image: {e}");
5376        }
5377        handle
5378    }
5379
5380    /// Register raw RGBA8 pixels (`w * h * 4` bytes) as an image, returning
5381    /// its handle for `SceneNode::Image`. Used by CPU-rasterized overlays
5382    /// (e.g. subtitle bitmap layers) that have no encoded image bytes.
5383    /// Pass `srgb = true` for sRGB overlays composited over video.
5384    pub fn register_image_rgba8(&mut self, w: u32, h: u32, rgba: &[u8], srgb: bool) -> u64 {
5385        let handle = self.next_image_handle;
5386        self.next_image_handle += 1;
5387        if let Err(e) = self.set_image_rgba8(handle, w, h, rgba, srgb) {
5388            log::error!("Failed to register image: {e}");
5389        }
5390        handle
5391    }
5392
5393    /// Register an 8-bit coverage tile (`w * h` bytes, 0 = empty, 255 =
5394    /// fully covered) for `SceneNode::Coverage`, returning its handle.
5395    /// Coverage tiles are immutable: re-register on geometry change and
5396    /// `remove_coverage` handles you no longer emit (stale tiles also age
5397    /// out under the image eviction policy).
5398    pub fn register_coverage_a8(&mut self, w: u32, h: u32, coverage: &[u8]) -> u64 {
5399        if validate_texture_dimensions(&self.device, w, h).is_err() {
5400            log::error!("Coverage dimensions are outside the device limit");
5401            return 0;
5402        }
5403        let Ok(expected_bytes) = checked_image_bytes(w, h, 1) else {
5404            log::error!("Coverage dimensions overflow");
5405            return 0;
5406        };
5407        let Ok(expected) = usize::try_from(expected_bytes) else {
5408            log::error!("Coverage dimensions exceed addressable memory");
5409            return 0;
5410        };
5411        if coverage.len() < expected {
5412            log::error!("Coverage buffer too small: {} < {expected}", coverage.len());
5413            return 0;
5414        }
5415        let handle = self.next_coverage_handle;
5416        self.next_coverage_handle += 1;
5417
5418        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5419            label: Some("coverage tile a8"),
5420            size: wgpu::Extent3d {
5421                width: w,
5422                height: h,
5423                depth_or_array_layers: 1,
5424            },
5425            mip_level_count: 1,
5426            sample_count: 1,
5427            dimension: wgpu::TextureDimension::D2,
5428            format: wgpu::TextureFormat::R8Unorm,
5429            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5430            view_formats: &[],
5431        });
5432        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5433        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5434            label: Some("coverage bind a8"),
5435            layout: &self.image_bind_layout_rgba,
5436            entries: &[
5437                wgpu::BindGroupEntry {
5438                    binding: 0,
5439                    resource: wgpu::BindingResource::TextureView(&view),
5440                },
5441                wgpu::BindGroupEntry {
5442                    binding: 1,
5443                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5444                },
5445            ],
5446        });
5447        self.queue.write_texture(
5448            wgpu::TexelCopyTextureInfo {
5449                texture: &tex,
5450                mip_level: 0,
5451                origin: wgpu::Origin3d::ZERO,
5452                aspect: wgpu::TextureAspect::All,
5453            },
5454            &coverage[..expected],
5455            wgpu::TexelCopyBufferLayout {
5456                offset: 0,
5457                bytes_per_row: Some(w),
5458                rows_per_image: Some(h),
5459            },
5460            wgpu::Extent3d {
5461                width: w,
5462                height: h,
5463                depth_or_array_layers: 1,
5464            },
5465        );
5466        let bytes = checked_image_bytes(w, h, 1).unwrap_or(u64::MAX);
5467        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5468        self.coverages.insert(
5469            handle,
5470            CoverageTex {
5471                tex,
5472                bind,
5473                w,
5474                h,
5475                last_used_frame: self.frame_index,
5476                bytes,
5477            },
5478        );
5479        self.evict_budget_excess();
5480        handle
5481    }
5482
5483    /// Remove a coverage tile registered with [`register_coverage_a8`](Self::register_coverage_a8).
5484    pub fn remove_coverage(&mut self, handle: u64) {
5485        if let Some(tile) = self.coverages.remove(&handle) {
5486            self.image_bytes_total = self.image_bytes_total.saturating_sub(tile.bytes);
5487        }
5488    }
5489
5490    /// Tile dimensions, marking the handle used (keeps it alive under the
5491    /// eviction policy). Returns `None` for unknown handles.
5492    pub fn coverage_dimensions(&mut self, handle: u64) -> Option<(u32, u32)> {
5493        if let Some(tile) = self.coverages.get_mut(&handle) {
5494            tile.last_used_frame = self.frame_index;
5495            return Some((tile.w, tile.h));
5496        }
5497        None
5498    }
5499
5500    fn evict_retained_excess(&mut self) {
5501        let mut candidates: Vec<(u64, u64, u64)> = self
5502            .retained
5503            .iter()
5504            .map(|(handle, image)| (*handle, image.last_used_frame, image.rgba.len() as u64))
5505            .collect();
5506        candidates.sort_by_key(|candidate| candidate.1);
5507        for (handle, _last_used, bytes) in candidates {
5508            if self.retained.len() <= MAX_RETAINED_IMAGES
5509                && self.retained_bytes_total <= MAX_RETAINED_IMAGE_BYTES
5510            {
5511                break;
5512            }
5513            if let Some(image) = self.retained.remove(&handle) {
5514                self.retained_bytes_total = self
5515                    .retained_bytes_total
5516                    .saturating_sub(bytes.min(image.rgba.len() as u64));
5517            }
5518        }
5519    }
5520
5521    fn evict_unused_images(&mut self) {
5522        let now = self.frame_index;
5523        let evict_after = self.image_evict_after_frames;
5524
5525        // Time based eviction. Eviction only frees GPU memory: retained RGBA
5526        // sources stay so the image can be lazily re-uploaded when drawn again.
5527        let mut to_evict = Vec::new();
5528        for (h, t) in self.images.iter() {
5529            if !t.evictable() {
5530                continue;
5531            }
5532            let last = match t {
5533                ImageTex::Rgba {
5534                    last_used_frame, ..
5535                } => *last_used_frame,
5536                ImageTex::User {
5537                    last_used_frame, ..
5538                } => *last_used_frame,
5539                ImageTex::Nv12 {
5540                    last_used_frame, ..
5541                } => *last_used_frame,
5542            };
5543            if now.saturating_sub(last) > evict_after {
5544                to_evict.push(*h);
5545            }
5546        }
5547        for h in to_evict {
5548            if self.retained.contains_key(&h) {
5549                self.evict_image_gpu(h);
5550            } else {
5551                self.remove_image(h);
5552            }
5553        }
5554
5555        // Coverage tiles have no retained CPU copies: age-out removes them.
5556        let mut stale = Vec::new();
5557        for (h, t) in self.coverages.iter() {
5558            if now.saturating_sub(t.last_used_frame) > evict_after {
5559                stale.push(*h);
5560            }
5561        }
5562        for h in stale {
5563            self.remove_coverage(h);
5564        }
5565
5566        self.evict_budget_excess();
5567    }
5568
5569    fn next_cache_touch(&mut self) -> u64 {
5570        let touch = self.cache_touch_clock;
5571        self.cache_touch_clock = self.cache_touch_clock.wrapping_add(1);
5572        touch
5573    }
5574
5575    fn gpu_bytes_total(&self) -> u64 {
5576        self.image_bytes_total
5577            .saturating_add(self.layer_bytes_total)
5578            .saturating_add(self.blend_snapshot_bytes_total)
5579            .saturating_add(self.mesh_cache_bytes_total)
5580            .saturating_add(self.working_space_bytes)
5581            .saturating_add(self.surface_resolve_bytes)
5582            .saturating_add(self.msaa_bytes)
5583            .saturating_add(self.ws_msaa_bytes)
5584            .saturating_add(self.depth_stencil_bytes)
5585    }
5586
5587    fn budget_exceeded(&self) -> bool {
5588        self.image_bytes_total > self.image_budget_bytes
5589            || self.gpu_bytes_total() > self.gpu_budget_bytes
5590    }
5591
5592    fn evict_budget_excess(&mut self) {
5593        let image_over_budget = self.image_bytes_total > self.image_budget_bytes;
5594        let gpu_over_budget = self.gpu_bytes_total() > self.gpu_budget_bytes;
5595        if !image_over_budget && !gpu_over_budget {
5596            return;
5597        }
5598        let mut coverage_candidates: Vec<(u64, u64)> = self
5599            .coverages
5600            .iter()
5601            .map(|(handle, tile)| (*handle, tile.last_used_frame))
5602            .collect();
5603        coverage_candidates.sort_by_key(|candidate| candidate.1);
5604        let now = self.frame_index;
5605        for (handle, last_used) in coverage_candidates {
5606            if !self.budget_exceeded() {
5607                break;
5608            }
5609            if last_used == now {
5610                continue;
5611            }
5612            self.remove_coverage(handle);
5613        }
5614        if !self.budget_exceeded() {
5615            return;
5616        }
5617        // Collect (handle, last_used, bytes)
5618        let mut candidates: Vec<(u64, u64, u64)> = self
5619            .images
5620            .iter()
5621            .filter_map(|(h, t)| {
5622                if !t.evictable() {
5623                    return None;
5624                }
5625                let (last, bytes) = match t {
5626                    ImageTex::Rgba {
5627                        last_used_frame,
5628                        bytes,
5629                        ..
5630                    } => (*last_used_frame, *bytes),
5631                    ImageTex::User {
5632                        last_used_frame,
5633                        bytes,
5634                        ..
5635                    } => (*last_used_frame, *bytes),
5636                    ImageTex::Nv12 {
5637                        last_used_frame,
5638                        bytes,
5639                        ..
5640                    } => (*last_used_frame, *bytes),
5641                };
5642                Some((*h, last, bytes))
5643            })
5644            .collect();
5645
5646        // Sort by last_used ascending (LRU first)
5647        candidates.sort_by_key(|k| k.1);
5648
5649        for (h, last, _bytes) in candidates {
5650            if !self.budget_exceeded() {
5651                break;
5652            }
5653            if last == now {
5654                continue;
5655            }
5656            if self.retained.contains_key(&h) {
5657                self.evict_image_gpu(h);
5658            } else {
5659                self.remove_image(h);
5660            }
5661        }
5662        while self.gpu_bytes_total() > self.gpu_budget_bytes {
5663            if !self.evict_mesh_cache() {
5664                break;
5665            }
5666        }
5667        if self.gpu_bytes_total() > self.gpu_budget_bytes {
5668            log::warn!(
5669                "renderer GPU resource budget exceeded: {} > {}",
5670                self.gpu_bytes_total(),
5671                self.gpu_budget_bytes
5672            );
5673        }
5674    }
5675
5676    /// Set pixels per point (DPI scale) for callback `ScreenDescriptor` / `PaintCallbackInfo`.
5677    pub fn set_pixels_per_point(&mut self, ppp: f32) {
5678        if ppp.is_finite() {
5679            self.pixels_per_point = ppp.clamp(0.5, 8.0);
5680        }
5681    }
5682
5683    /// Enable or disable linear working-space rendering.
5684    /// When enabled, the scene is rendered into an Rgba16Float intermediate
5685    /// and a final full-screen pass applies the display OETF.
5686    pub fn set_working_space(&mut self, enabled: bool) {
5687        if enabled == self.working_space {
5688            return;
5689        }
5690        self.working_space = enabled;
5691        if enabled {
5692            self.ensure_display_pipeline();
5693            self.recreate_msaa_and_depth_stencil();
5694            self.recreate_working_space_texture();
5695        } else {
5696            self.ws_tex = None;
5697            self.ws_view = None;
5698            self.ws_bind = None;
5699            self.ws_msaa_tex = None;
5700            self.ws_msaa_view = None;
5701            self.surface_resolve_tex = None;
5702            self.surface_resolve_view = None;
5703            self.surface_resolve_bytes = 0;
5704            self.working_space_bytes = 0;
5705            self.recreate_msaa_and_depth_stencil();
5706        }
5707        self.evict_budget_excess();
5708    }
5709
5710    fn ensure_display_pipeline(&mut self) {
5711        if self.display_pipeline.is_some() {
5712            return;
5713        }
5714
5715        let layout = self
5716            .device
5717            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5718                label: Some("display transform layout"),
5719                entries: &[
5720                    wgpu::BindGroupLayoutEntry {
5721                        binding: 0,
5722                        visibility: wgpu::ShaderStages::FRAGMENT,
5723                        ty: wgpu::BindingType::Texture {
5724                            multisampled: false,
5725                            view_dimension: wgpu::TextureViewDimension::D2,
5726                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5727                        },
5728                        count: None,
5729                    },
5730                    wgpu::BindGroupLayoutEntry {
5731                        binding: 1,
5732                        visibility: wgpu::ShaderStages::FRAGMENT,
5733                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5734                        count: None,
5735                    },
5736                ],
5737            });
5738        self.display_layout = Some(layout);
5739
5740        let display_source: Cow<'static, str> = if self.output_format.is_srgb() {
5741            Cow::Borrowed(include_str!("shaders/display_transform_passthrough.wgsl"))
5742        } else {
5743            Cow::Borrowed(include_str!("shaders/display_transform.wgsl"))
5744        };
5745        let shader = self
5746            .device
5747            .create_shader_module(wgpu::ShaderModuleDescriptor {
5748                label: Some("display transform"),
5749                source: wgpu::ShaderSource::Wgsl(display_source),
5750            });
5751
5752        let pipeline_layout = self
5753            .device
5754            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
5755                label: Some("display transform pipeline layout"),
5756                bind_group_layouts: &[None, self.display_layout.as_ref()],
5757                immediate_size: 0,
5758            });
5759
5760        let pipeline = self
5761            .device
5762            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
5763                label: Some("display transform pipeline"),
5764                layout: Some(&pipeline_layout),
5765                vertex: wgpu::VertexState {
5766                    module: &shader,
5767                    entry_point: Some("vs_main"),
5768                    buffers: &[],
5769                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5770                },
5771                fragment: Some(wgpu::FragmentState {
5772                    module: &shader,
5773                    entry_point: Some("fs_main"),
5774                    targets: &[Some(wgpu::ColorTargetState {
5775                        format: self.output_format,
5776                        blend: None,
5777                        write_mask: wgpu::ColorWrites::ALL,
5778                    })],
5779                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5780                }),
5781                primitive: wgpu::PrimitiveState::default(),
5782                depth_stencil: None,
5783                multisample: wgpu::MultisampleState::default(),
5784                multiview_mask: None,
5785                cache: None,
5786            });
5787        self.display_pipeline = Some(pipeline);
5788    }
5789
5790    /// Resize the render target dimensions.
5791    ///
5792    /// Recreates MSAA, depth-stencil, and working-space textures to match the
5793    /// new size..
5794    pub fn resize(&mut self, width: u32, height: u32) {
5795        let (width, height) =
5796            clamp_output_size(width, height, self.device.limits().max_texture_dimension_2d);
5797        if self.output_width == width && self.output_height == height {
5798            return;
5799        }
5800        self.output_width = width;
5801        self.output_height = height;
5802        if width == 0 || height == 0 {
5803            self.msaa_tex = None;
5804            self.msaa_view = None;
5805            self.ws_msaa_tex = None;
5806            self.ws_msaa_view = None;
5807            self.surface_resolve_tex = None;
5808            self.surface_resolve_view = None;
5809            self.surface_resolve_bytes = 0;
5810            self.msaa_bytes = 0;
5811            self.ws_msaa_bytes = 0;
5812            self.depth_stencil_bytes = 0;
5813            self.working_space_bytes = 0;
5814            self.ws_tex = None;
5815            self.ws_view = None;
5816            self.ws_bind = None;
5817            self.evict_budget_excess();
5818            return;
5819        }
5820        self.recreate_msaa_and_depth_stencil();
5821        self.recreate_working_space_texture();
5822        self.evict_budget_excess();
5823    }
5824
5825    fn recreate_working_space_texture(&mut self) {
5826        if !self.working_space || self.output_width == 0 || self.output_height == 0 {
5827            return;
5828        }
5829        self.working_space_bytes = 0;
5830        let w = self
5831            .output_width
5832            .clamp(1, self.device.limits().max_texture_dimension_2d);
5833        let h = self
5834            .output_height
5835            .clamp(1, self.device.limits().max_texture_dimension_2d);
5836
5837        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5838            label: Some("working space"),
5839            size: wgpu::Extent3d {
5840                width: w,
5841                height: h,
5842                depth_or_array_layers: 1,
5843            },
5844            mip_level_count: 1,
5845            sample_count: 1,
5846            dimension: wgpu::TextureDimension::D2,
5847            format: wgpu::TextureFormat::Rgba16Float,
5848            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5849                | wgpu::TextureUsages::TEXTURE_BINDING
5850                | wgpu::TextureUsages::COPY_SRC
5851                | wgpu::TextureUsages::COPY_DST,
5852            view_formats: &[],
5853        });
5854        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5855
5856        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5857            label: Some("working space bind"),
5858            layout: self.display_layout.as_ref().unwrap(),
5859            entries: &[
5860                wgpu::BindGroupEntry {
5861                    binding: 0,
5862                    resource: wgpu::BindingResource::TextureView(&view),
5863                },
5864                wgpu::BindGroupEntry {
5865                    binding: 1,
5866                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5867                },
5868            ],
5869        });
5870
5871        self.working_space_bytes =
5872            texture_storage_bytes(wgpu::TextureFormat::Rgba16Float, w, h).unwrap_or(0);
5873        self.ws_tex = Some(tex);
5874        self.ws_view = Some(view);
5875        self.ws_bind = Some(bind);
5876    }
5877
5878    fn recreate_msaa_and_depth_stencil(&mut self) {
5879        if self.output_width == 0 || self.output_height == 0 {
5880            self.msaa_tex = None;
5881            self.msaa_view = None;
5882            self.ws_msaa_tex = None;
5883            self.ws_msaa_view = None;
5884            self.surface_resolve_tex = None;
5885            self.surface_resolve_view = None;
5886            self.surface_resolve_bytes = 0;
5887            self.msaa_bytes = 0;
5888            self.ws_msaa_bytes = 0;
5889            self.depth_stencil_bytes = 0;
5890            return;
5891        }
5892        self.msaa_bytes = 0;
5893        self.ws_msaa_bytes = 0;
5894        self.depth_stencil_bytes = 0;
5895        let aliased_surface = self.surface_format != self.output_format;
5896        let view_format_list = [self.output_format];
5897        let surface_view_formats: &[wgpu::TextureFormat] = if aliased_surface {
5898            &view_format_list
5899        } else {
5900            &[]
5901        };
5902        let surface_view = if aliased_surface {
5903            wgpu::TextureViewDescriptor {
5904                format: Some(self.output_format),
5905                ..Default::default()
5906            }
5907        } else {
5908            wgpu::TextureViewDescriptor::default()
5909        };
5910        if self.msaa_samples > 1 && !self.working_space {
5911            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5912                label: Some("msaa color"),
5913                size: wgpu::Extent3d {
5914                    width: self.output_width.max(1),
5915                    height: self.output_height.max(1),
5916                    depth_or_array_layers: 1,
5917                },
5918                mip_level_count: 1,
5919                sample_count: self.msaa_samples,
5920                dimension: wgpu::TextureDimension::D2,
5921                format: self.surface_format,
5922                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5923                view_formats: surface_view_formats,
5924            });
5925            let view = tex.create_view(&surface_view);
5926            self.msaa_bytes = texture_storage_bytes_with_samples(
5927                self.surface_format,
5928                self.output_width.max(1),
5929                self.output_height.max(1),
5930                self.msaa_samples,
5931            )
5932            .unwrap_or(0);
5933            self.msaa_tex = Some(tex);
5934            self.msaa_view = Some(view);
5935        } else {
5936            self.msaa_tex = None;
5937            self.msaa_view = None;
5938        }
5939        if self.working_space && self.working_space_msaa_samples > 1 {
5940            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5941                label: Some("working space msaa color"),
5942                size: wgpu::Extent3d {
5943                    width: self.output_width.max(1),
5944                    height: self.output_height.max(1),
5945                    depth_or_array_layers: 1,
5946                },
5947                mip_level_count: 1,
5948                sample_count: self.working_space_msaa_samples,
5949                dimension: wgpu::TextureDimension::D2,
5950                format: wgpu::TextureFormat::Rgba16Float,
5951                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5952                view_formats: &[],
5953            });
5954            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5955            self.ws_msaa_bytes = texture_storage_bytes_with_samples(
5956                wgpu::TextureFormat::Rgba16Float,
5957                self.output_width.max(1),
5958                self.output_height.max(1),
5959                self.working_space_msaa_samples,
5960            )
5961            .unwrap_or(0);
5962            self.ws_msaa_tex = Some(tex);
5963            self.ws_msaa_view = Some(view);
5964        } else {
5965            self.ws_msaa_tex = None;
5966            self.ws_msaa_view = None;
5967        }
5968        self.surface_resolve_tex = None;
5969        self.surface_resolve_view = None;
5970        self.surface_resolve_bytes = 0;
5971        if self.msaa_samples > 1 && !self.working_space {
5972            let width = self.output_width.max(1);
5973            let height = self.output_height.max(1);
5974            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5975                label: Some("surface resolve"),
5976                size: wgpu::Extent3d {
5977                    width,
5978                    height,
5979                    depth_or_array_layers: 1,
5980                },
5981                mip_level_count: 1,
5982                sample_count: 1,
5983                dimension: wgpu::TextureDimension::D2,
5984                format: self.surface_format,
5985                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5986                    | wgpu::TextureUsages::TEXTURE_BINDING
5987                    | wgpu::TextureUsages::COPY_SRC,
5988                view_formats: surface_view_formats,
5989            });
5990            let view = tex.create_view(&surface_view);
5991            self.surface_resolve_bytes =
5992                texture_storage_bytes(self.surface_format, width, height).unwrap_or(0);
5993            self.surface_resolve_tex = Some(tex);
5994            self.surface_resolve_view = Some(view);
5995        }
5996
5997        self.depth_stencil_bytes = texture_storage_bytes_with_samples(
5998            wgpu::TextureFormat::Depth24PlusStencil8,
5999            self.output_width.max(1),
6000            self.output_height.max(1),
6001            self.active_surface_msaa_samples(),
6002        )
6003        .unwrap_or(0);
6004        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
6005            label: Some("depth-stencil (stencil clips)"),
6006            size: wgpu::Extent3d {
6007                width: self.output_width.max(1),
6008                height: self.output_height.max(1),
6009                depth_or_array_layers: 1,
6010            },
6011            mip_level_count: 1,
6012            sample_count: self.active_surface_msaa_samples(),
6013            dimension: wgpu::TextureDimension::D2,
6014            format: wgpu::TextureFormat::Depth24PlusStencil8,
6015            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6016            view_formats: &[],
6017        });
6018        self.depth_stencil_view = self
6019            .depth_stencil_tex
6020            .create_view(&wgpu::TextureViewDescriptor::default());
6021    }
6022
6023    fn active_surface_msaa_samples(&self) -> u32 {
6024        if self.working_space {
6025            self.working_space_msaa_samples
6026        } else {
6027            self.msaa_samples
6028        }
6029    }
6030
6031    fn layer_target_format(&self) -> wgpu::TextureFormat {
6032        if self.working_space {
6033            wgpu::TextureFormat::Rgba16Float
6034        } else {
6035            self.output_format
6036        }
6037    }
6038
6039    fn get_or_create_layer(
6040        &mut self,
6041        layer_id: u32,
6042        width: u32,
6043        height: u32,
6044        rect: repose_core::Rect,
6045        transient: bool,
6046    ) -> bool {
6047        if width == 0
6048            || height == 0
6049            || width > self.device.limits().max_texture_dimension_2d
6050            || height > self.device.limits().max_texture_dimension_2d
6051        {
6052            return false;
6053        }
6054        let needs_alloc = match self.layer_pool.get(&layer_id) {
6055            Some(lt) => {
6056                lt.width != width || lt.height != height || lt.format != self.layer_target_format()
6057            }
6058            None => true,
6059        };
6060        if !needs_alloc {
6061            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
6062                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
6063            }
6064            return true;
6065        }
6066        let color_bytes =
6067            texture_storage_bytes(self.layer_target_format(), width.max(1), height.max(1))
6068                .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6069        let depth_bytes = checked_image_bytes(width.max(1), height.max(1), 4)
6070            .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6071        let bytes = color_bytes.saturating_add(depth_bytes);
6072        let old = self.layer_pool.get(&layer_id);
6073        let old_bytes = old.map_or(0, |layer| layer.bytes);
6074        let old_transient_bytes =
6075            old.map_or(0, |layer| if layer.transient { layer.bytes } else { 0 });
6076        let transient_count = self
6077            .layer_pool
6078            .values()
6079            .filter(|layer| layer.transient)
6080            .count();
6081        let transient_count = transient_count
6082            .saturating_sub(usize::from(old.is_some_and(|layer| layer.transient)))
6083            .saturating_add(usize::from(transient));
6084        let count = self.layer_pool.len() + usize::from(!self.layer_pool.contains_key(&layer_id));
6085        let total = self
6086            .layer_bytes_total
6087            .saturating_sub(old_bytes)
6088            .saturating_add(bytes);
6089        let projected_gpu = self
6090            .gpu_bytes_total()
6091            .saturating_sub(old_bytes)
6092            .saturating_add(bytes);
6093        let projected_transient = self
6094            .transient_layer_bytes_total
6095            .saturating_sub(old_transient_bytes)
6096            .saturating_add(if transient { bytes } else { 0 });
6097        if count > MAX_GRAPHICS_LAYERS
6098            || (transient
6099                && (transient_count > MAX_TRANSIENT_GRAPHICS_LAYERS
6100                    || projected_transient > MAX_TRANSIENT_GRAPHICS_LAYER_BYTES))
6101            || bytes > MAX_GRAPHICS_LAYER_BYTES
6102            || total > MAX_GRAPHICS_LAYER_BYTES
6103            || projected_gpu > self.gpu_budget_bytes
6104        {
6105            log::warn!("graphics layer budget exhausted; layer {layer_id} skipped");
6106            if let Some(old) = self.layer_pool.remove(&layer_id) {
6107                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6108                if old.transient {
6109                    self.transient_layer_bytes_total =
6110                        self.transient_layer_bytes_total.saturating_sub(old.bytes);
6111                }
6112            }
6113            return false;
6114        }
6115        if let Some(old) = self.layer_pool.remove(&layer_id) {
6116            self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6117            if old.transient {
6118                self.transient_layer_bytes_total =
6119                    self.transient_layer_bytes_total.saturating_sub(old.bytes);
6120            }
6121        }
6122        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6123            label: Some("graphics layer"),
6124            size: wgpu::Extent3d {
6125                width: width.max(1),
6126                height: height.max(1),
6127                depth_or_array_layers: 1,
6128            },
6129            mip_level_count: 1,
6130            sample_count: 1,
6131            dimension: wgpu::TextureDimension::D2,
6132            format: self.layer_target_format(),
6133            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6134                | wgpu::TextureUsages::TEXTURE_BINDING
6135                | wgpu::TextureUsages::COPY_SRC,
6136            view_formats: &[],
6137        });
6138        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6139        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6140            label: Some("layer bind"),
6141            layout: &self.image_bind_layout_rgba,
6142            entries: &[
6143                wgpu::BindGroupEntry {
6144                    binding: 0,
6145                    resource: wgpu::BindingResource::TextureView(&view),
6146                },
6147                wgpu::BindGroupEntry {
6148                    binding: 1,
6149                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
6150                },
6151            ],
6152        });
6153        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6154            label: Some("layer bind linear"),
6155            layout: &self.image_bind_layout_rgba,
6156            entries: &[
6157                wgpu::BindGroupEntry {
6158                    binding: 0,
6159                    resource: wgpu::BindingResource::TextureView(&view),
6160                },
6161                wgpu::BindGroupEntry {
6162                    binding: 1,
6163                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
6164                },
6165            ],
6166        });
6167        let depth_stencil_view = self.create_layer_depth_stencil(width, height);
6168        self.layer_pool.insert(
6169            layer_id,
6170            LayerTarget {
6171                texture: tex,
6172                view,
6173                bind,
6174                bind_linear,
6175                depth_stencil_view,
6176                width,
6177                height,
6178                format: self.layer_target_format(),
6179                bytes,
6180                rect_px: (rect.x, rect.y, rect.w, rect.h),
6181                transient,
6182            },
6183        );
6184        self.layer_bytes_total = self.layer_bytes_total.saturating_add(bytes);
6185        if transient {
6186            self.transient_layer_bytes_total =
6187                self.transient_layer_bytes_total.saturating_add(bytes);
6188        }
6189        true
6190    }
6191
6192    /// Allocate (or reuse) the padded scratch layer holding the horizontal half
6193    /// of a separable blur for `layer_id` in `slot`.
6194    ///
6195    /// Scratch ids are handed out from [`BLUR_SCRATCH_ID_BASE`] and remembered
6196    /// per `(layer_id, slot)` in [`Self::blur_scratch_ids`], so a stable layer
6197    /// reuses its allocation across frames while a content blur and a shadow
6198    /// on the same layer never share one. Allocation goes through the layer
6199    /// pool (as a transient layer) and therefore respects the layer budget;
6200    /// `None` means the caller should composite unblurred.
6201    fn acquire_blur_scratch(&mut self, layer_id: u32, slot: u32, pad: (f32, f32)) -> Option<u32> {
6202        let src = self.layer_pool.get(&layer_id)?;
6203        let width_f = (src.width as f32 + pad.0 * 2.0).ceil();
6204        let height_f = (src.height as f32 + pad.1 * 2.0).ceil();
6205        let max_dim = self.device.limits().max_texture_dimension_2d as f32;
6206        if !width_f.is_finite()
6207            || !height_f.is_finite()
6208            || width_f < 1.0
6209            || height_f < 1.0
6210            || width_f > max_dim
6211            || height_f > max_dim
6212        {
6213            log::warn!("blur scratch size {width_f}x{height_f} is invalid");
6214            return None;
6215        }
6216        let next = self.blur_scratch_next;
6217        let scratch_id = match self.blur_scratch_ids.get(&(layer_id, slot)) {
6218            Some(&id) => id,
6219            None => {
6220                let id = BLUR_SCRATCH_ID_BASE.wrapping_add(next);
6221                self.blur_scratch_next = next.wrapping_add(1);
6222                self.blur_scratch_ids.insert((layer_id, slot), id);
6223                id
6224            }
6225        };
6226        let allocated = self.get_or_create_layer(
6227            scratch_id,
6228            width_f as u32,
6229            height_f as u32,
6230            repose_core::Rect {
6231                x: 0.0,
6232                y: 0.0,
6233                w: width_f,
6234                h: height_f,
6235            },
6236            true,
6237        );
6238        allocated.then_some(scratch_id)
6239    }
6240
6241    /// Emit the horizontal half of a separable blur for `source_layer` into a
6242    /// padded scratch layer, and return that scratch's id so the caller can
6243    /// finish the vertical axis when it composites.
6244    ///
6245    /// The scratch pass is spliced between the pass being built and the pass the
6246    /// caller continues in, so ordering stays: everything drawn so far, the
6247    /// horizontal blur, then the vertical composite. `None` means no scratch
6248    /// could be allocated and the caller should composite without the blur.
6249    ///
6250    /// `slot` picks the scratch this blur owns, so concurrent blurs of one
6251    /// layer (content and shadow) get their own texture. The horizontal
6252    /// instance carries no tint: the scratch holds the raw blurred signal and
6253    /// the caller applies the group alpha / shadow colour on the vertical pass,
6254    /// which would otherwise square the alpha across the two axes.
6255    ///
6256    /// `alpha_only` selects the shadow kernel (alpha, premultiplied by the
6257    /// tint the caller applies vertically) over the full-RGBA kernel used by
6258    /// `Modifier::blur`.
6259    #[allow(clippy::too_many_arguments)]
6260    fn emit_separable_blur(
6261        &mut self,
6262        source_layer: u32,
6263        slot: u32,
6264        sigma: [f32; 2],
6265        pad: (f32, f32),
6266        edge_mode: u32,
6267        alpha_only: bool,
6268        shape: [f32; 4],
6269        passes: &mut Vec<Pass>,
6270        current_pass: &mut Pass,
6271        next_pass_id: &mut u64,
6272        encoder: &mut wgpu::CommandEncoder,
6273    ) -> Option<u32> {
6274        let scratch_id = self.acquire_blur_scratch(source_layer, slot, pad)?;
6275        let (scratch_w, scratch_h, source_w, source_h) = {
6276            let scratch = self.layer_pool.get(&scratch_id)?;
6277            let source = self.layer_pool.get(&source_layer)?;
6278            (
6279                scratch.width,
6280                scratch.height,
6281                source.width as f32,
6282                source.height as f32,
6283            )
6284        };
6285        // The quad covers the whole scratch, and scratch texel (x, y) samples
6286        // the source at (x - pad_x, y - pad_y). Padding texels therefore land
6287        // outside the source's 0..1, and `edge_mode` decides whether that
6288        // contributes nothing (decal, correct for a shadow) or clamps to the
6289        // edge texel (the `Modifier::blur` clamp edge treatment).
6290        let uv = [
6291            -pad.0 / source_w,
6292            -pad.1 / source_h,
6293            1.0 + pad.0 / source_w,
6294            1.0 + pad.1 / source_h,
6295        ];
6296        let ndc = [-1.0, -1.0, 2.0, 2.0];
6297        let inst = BlurInstance {
6298            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
6299            uv,
6300            color: [1.0, 1.0, 1.0, 1.0],
6301            sigma_px: sigma,
6302            fwd_mat: [1.0, 0.0, 0.0, 1.0],
6303            axis: 0,
6304            edge_mode,
6305            shape,
6306        };
6307        let cmd = match self.upload_blur_instance(&inst, encoder) {
6308            Some(off) if alpha_only => Cmd::CompositeShadow {
6309                off,
6310                cnt: 1,
6311                layer_id: source_layer,
6312            },
6313            Some(off) => Cmd::CompositeBlur {
6314                off,
6315                cnt: 1,
6316                layer_id: source_layer,
6317            },
6318            None => return None,
6319        };
6320        let target = current_pass.target;
6321        let scissor = current_pass.active_scissor;
6322        let resumed_pass_id = *next_pass_id;
6323        *next_pass_id += 1;
6324        let saved = std::mem::replace(
6325            current_pass,
6326            Pass {
6327                id: resumed_pass_id,
6328                target,
6329                initial_scissor: scissor.unwrap_or((0, 0, 1, 1)),
6330                active_scissor: scissor,
6331                clear_color: None,
6332                cmds: Vec::new(),
6333            },
6334        );
6335        passes.push(saved);
6336        let scratch_pass_id = *next_pass_id;
6337        *next_pass_id += 1;
6338        passes.push(Pass {
6339            id: scratch_pass_id,
6340            target: PassTarget::Layer(scratch_id),
6341            initial_scissor: (0, 0, scratch_w, scratch_h),
6342            active_scissor: Some((0, 0, scratch_w, scratch_h)),
6343            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
6344            cmds: vec![cmd],
6345        });
6346        Some(scratch_id)
6347    }
6348
6349    fn upload_blur_instance(
6350        &mut self,
6351        inst: &BlurInstance,
6352        encoder: &mut wgpu::CommandEncoder,
6353    ) -> Option<u64> {
6354        self.blur_ring
6355            .grow_to_fit(
6356                &self.device,
6357                encoder,
6358                std::mem::size_of::<BlurInstance>() as u64,
6359            )
6360            .ok()?;
6361        self.blur_ring
6362            .alloc_write(&self.queue, bytemuck::bytes_of(inst))
6363            .ok()
6364    }
6365    fn create_layer_depth_stencil(&self, width: u32, height: u32) -> wgpu::TextureView {
6366        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6367            label: Some("layer depth-stencil"),
6368            size: wgpu::Extent3d {
6369                width: width.max(1),
6370                height: height.max(1),
6371                depth_or_array_layers: 1,
6372            },
6373            mip_level_count: 1,
6374            sample_count: 1,
6375            dimension: wgpu::TextureDimension::D2,
6376            format: wgpu::TextureFormat::Depth24PlusStencil8,
6377            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6378            view_formats: &[],
6379        });
6380        tex.create_view(&wgpu::TextureViewDescriptor::default())
6381    }
6382
6383    fn ensure_globals_staging(&mut self, pass_count: usize) -> bool {
6384        let size = std::mem::size_of::<Globals>() as u64;
6385        let needed = match (pass_count.max(1) as u64).checked_mul(size) {
6386            Some(needed) => needed,
6387            None => return false,
6388        };
6389        if needed <= self.globals_staging_cap {
6390            return true;
6391        }
6392        let cap = needed
6393            .next_power_of_two()
6394            .min(self.device.limits().max_buffer_size);
6395        if cap < needed {
6396            return false;
6397        }
6398        self.globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
6399            label: Some("globals staging"),
6400            size: cap,
6401            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
6402            mapped_at_creation: false,
6403        });
6404        self.globals_staging_cap = cap;
6405        true
6406    }
6407
6408    fn reset_atlas_storage(&mut self) {
6409        self.atlas_mask = init_atlas_mask(&self.device);
6410        self.atlas_color = init_atlas_color(&self.device);
6411        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6412            label: Some("atlas mask bind"),
6413            layout: &self.text_bind_layout,
6414            entries: &[
6415                wgpu::BindGroupEntry {
6416                    binding: 0,
6417                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6418                },
6419                wgpu::BindGroupEntry {
6420                    binding: 1,
6421                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6422                },
6423            ],
6424        });
6425        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6426            label: Some("atlas color bind"),
6427            layout: &self.text_bind_layout,
6428            entries: &[
6429                wgpu::BindGroupEntry {
6430                    binding: 0,
6431                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6432                },
6433                wgpu::BindGroupEntry {
6434                    binding: 1,
6435                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6436                },
6437            ],
6438        });
6439        self.atlas_mask_pending = AtlasPending::default();
6440        self.atlas_color_pending = AtlasPending::default();
6441        self.atlas_mask_deferred.clear();
6442        self.atlas_color_deferred.clear();
6443    }
6444
6445    /// Apply atlas growth and compaction scheduled during the previous frame.
6446    /// Called from `begin_frame`, before any glyph is batched: both operations
6447    /// re-pack the atlas, so running them mid-frame would leave already-batched
6448    /// instances pointing at the old packing.
6449    fn apply_atlas_pending(&mut self) {
6450        if self.atlas_mask_pending.grow {
6451            self.atlas_mask_pending.grow = false;
6452            let size = (self.atlas_mask.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6453            self.repack_mask(size);
6454        }
6455        if self.atlas_color_pending.grow {
6456            self.atlas_color_pending.grow = false;
6457            let size = (self.atlas_color.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6458            self.repack_color(size);
6459        }
6460        if self.frame_index.wrapping_sub(self.atlas_last_compact_frame)
6461            < ATLAS_COMPACT_MIN_FRAME_INTERVAL
6462        {
6463            return;
6464        }
6465        // Stay armed when a compaction finds nothing evictable, so the deferred
6466        // glyphs it would rescue get another chance next frame.
6467        if self.atlas_mask_pending.compact && self.compact_atlas_mask() {
6468            self.atlas_mask_pending.compact = false;
6469        }
6470        if self.atlas_color_pending.compact && self.compact_atlas_color() {
6471            self.atlas_color_pending.compact = false;
6472        }
6473    }
6474
6475    /// Drop the least recently used mask glyphs and re-pack at the same size.
6476    /// A shelf packer cannot hand back an individual cell, so reclaiming space
6477    /// means evicting cold entries and rebuilding around the warm ones. Returns
6478    /// false when there was nothing to evict.
6479    fn compact_atlas_mask(&mut self) -> bool {
6480        let target = self.atlas_mask.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6481        if !evict_atlas_lru(
6482            &mut self.atlas_mask.map,
6483            &mut self.atlas_mask.used_area,
6484            self.frame_index,
6485            target,
6486        ) {
6487            return false;
6488        }
6489        let size = self.atlas_mask.size;
6490        self.repack_mask(size);
6491        self.atlas_last_compact_frame = self.frame_index;
6492        true
6493    }
6494
6495    fn compact_atlas_color(&mut self) -> bool {
6496        let target = self.atlas_color.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6497        if !evict_atlas_lru(
6498            &mut self.atlas_color.map,
6499            &mut self.atlas_color.used_area,
6500            self.frame_index,
6501            target,
6502        ) {
6503            return false;
6504        }
6505        let size = self.atlas_color.size;
6506        self.repack_color(size);
6507        self.atlas_last_compact_frame = self.frame_index;
6508        true
6509    }
6510
6511    /// Re-pack the mask atlas into a `new_size` texture and re-upload every
6512    /// surviving entry, preserving its recency so a repack does not make warm
6513    /// glyphs look cold.
6514    fn repack_mask(&mut self, new_size: u32) {
6515        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6516            label: Some("glyph atlas A8 (repacked)"),
6517            size: wgpu::Extent3d {
6518                width: new_size,
6519                height: new_size,
6520                depth_or_array_layers: 1,
6521            },
6522            mip_level_count: 1,
6523            sample_count: 1,
6524            dimension: wgpu::TextureDimension::D2,
6525            format: wgpu::TextureFormat::R8Unorm,
6526            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6527            view_formats: &[],
6528        });
6529        self.atlas_mask.tex = tex;
6530        self.atlas_mask.view = self
6531            .atlas_mask
6532            .tex
6533            .create_view(&wgpu::TextureViewDescriptor::default());
6534        self.atlas_mask.size = new_size;
6535        self.atlas_mask.next_x = 1;
6536        self.atlas_mask.next_y = 1;
6537        self.atlas_mask.row_h = 0;
6538        self.atlas_mask.used_area = 0;
6539        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6540            label: Some("atlas mask bind"),
6541            layout: &self.text_bind_layout,
6542            entries: &[
6543                wgpu::BindGroupEntry {
6544                    binding: 0,
6545                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6546                },
6547                wgpu::BindGroupEntry {
6548                    binding: 1,
6549                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6550                },
6551            ],
6552        });
6553        let survivors: Vec<_> = self.atlas_mask.map.drain().collect();
6554        self.atlas_mask_deferred.clear();
6555        for (key, previous) in survivors {
6556            let _ = self.upload_glyph_mask(key.clone(), f32::from_bits(key.font_size_bits));
6557            if let Some(info) = self.atlas_mask.map.get_mut(&key) {
6558                info.last_touch = previous.last_touch;
6559                info.last_used_frame = previous.last_used_frame;
6560            }
6561        }
6562    }
6563
6564    /// Re-pack the color atlas into a `new_size` texture. See [`Self::repack_mask`].
6565    fn repack_color(&mut self, new_size: u32) {
6566        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6567            label: Some("glyph atlas RGBA (repacked)"),
6568            size: wgpu::Extent3d {
6569                width: new_size,
6570                height: new_size,
6571                depth_or_array_layers: 1,
6572            },
6573            mip_level_count: 1,
6574            sample_count: 1,
6575            dimension: wgpu::TextureDimension::D2,
6576            format: wgpu::TextureFormat::Rgba8UnormSrgb,
6577            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6578            view_formats: &[],
6579        });
6580        self.atlas_color.tex = tex;
6581        self.atlas_color.view = self
6582            .atlas_color
6583            .tex
6584            .create_view(&wgpu::TextureViewDescriptor::default());
6585        self.atlas_color.size = new_size;
6586        self.atlas_color.next_x = 1;
6587        self.atlas_color.next_y = 1;
6588        self.atlas_color.row_h = 0;
6589        self.atlas_color.used_area = 0;
6590        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6591            label: Some("atlas color bind"),
6592            layout: &self.text_bind_layout,
6593            entries: &[
6594                wgpu::BindGroupEntry {
6595                    binding: 0,
6596                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6597                },
6598                wgpu::BindGroupEntry {
6599                    binding: 1,
6600                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6601                },
6602            ],
6603        });
6604        let survivors: Vec<_> = self.atlas_color.map.drain().collect();
6605        self.atlas_color_deferred.clear();
6606        for (key, previous) in survivors {
6607            let _ = self.upload_glyph_color(key.clone(), f32::from_bits(key.font_size_bits));
6608            if let Some(info) = self.atlas_color.map.get_mut(&key) {
6609                info.last_touch = previous.last_touch;
6610                info.last_used_frame = previous.last_used_frame;
6611            }
6612        }
6613    }
6614
6615    fn sync_text_caches(&mut self) {
6616        let generation = repose_text::font_generation();
6617        if generation == self.atlas_generation {
6618            return;
6619        }
6620        self.atlas_generation = generation;
6621        self.reset_atlas_storage();
6622        self.atlas_mask_failures.clear();
6623        self.atlas_color_failures.clear();
6624        self.glyph_outline_cache.clear();
6625        self.legacy_shaped_text_cache.clear();
6626        self.slug_draw_cache.clear();
6627        self.slug_draw_cache_bytes_total = 0;
6628        self.slug_cache = slug::GlyphSlugCache::new();
6629    }
6630
6631    fn shape_text_cached(
6632        &mut self,
6633        text: &Arc<str>,
6634        px: f32,
6635        line_height_ratio: f32,
6636        font_family: Option<&'static str>,
6637        font_weight: u16,
6638        font_style: u8,
6639        letter_spacing: f32,
6640        variation: Option<&Arc<str>>,
6641    ) -> Arc<repose_text::SharedShapedText> {
6642        repose_text::shape_line_cached(
6643            text.as_ref(),
6644            px,
6645            line_height_ratio,
6646            font_family,
6647            font_weight,
6648            font_style,
6649            letter_spacing,
6650            variation.map(AsRef::as_ref),
6651        )
6652    }
6653
6654    fn legacy_shape_text_cached(
6655        &mut self,
6656        text: &Arc<str>,
6657        px: f32,
6658        line_height_ratio: f32,
6659        font_family: Option<&'static str>,
6660        font_weight: u16,
6661        font_style: u8,
6662        letter_spacing: f32,
6663        variation: Option<&Arc<str>>,
6664    ) -> Arc<[repose_text::ShapedGlyph]> {
6665        let key = TextShapeKey {
6666            text: text.clone(),
6667            px_bits: px.to_bits(),
6668            line_height_bits: line_height_ratio.to_bits(),
6669            font_family,
6670            font_weight,
6671            font_style,
6672            letter_spacing_bits: letter_spacing.to_bits(),
6673            variation: variation.cloned(),
6674            font_generation: self.atlas_generation,
6675        };
6676        if let Some(entry) = self.legacy_shaped_text_cache.get_mut(&key) {
6677            entry.last_used_frame = self.frame_index;
6678            return entry.glyphs.clone();
6679        }
6680        let glyphs: Arc<[repose_text::ShapedGlyph]> = Arc::from(repose_text::shape_line(
6681            text.as_ref(),
6682            px,
6683            line_height_ratio,
6684            font_family,
6685            font_weight,
6686            font_style,
6687            letter_spacing,
6688            variation.map(AsRef::as_ref),
6689        ));
6690        self.legacy_shaped_text_cache.insert(
6691            key,
6692            TextShapeCacheEntry {
6693                glyphs: glyphs.clone(),
6694                last_used_frame: self.frame_index,
6695            },
6696        );
6697        while self.legacy_shaped_text_cache.len() > MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES {
6698            let Some(victim) = self
6699                .legacy_shaped_text_cache
6700                .iter()
6701                .min_by_key(|(_, entry)| entry.last_used_frame)
6702                .map(|(key, _)| key.clone())
6703            else {
6704                break;
6705            };
6706            self.legacy_shaped_text_cache.remove(&victim);
6707        }
6708        glyphs
6709    }
6710
6711    fn slug_draw_key(
6712        cache_key: &repose_text::CacheKey,
6713        fill: bool,
6714        stroke_key: Option<&slug::StrokeTessKey>,
6715        transform: &Transform,
6716        origin: (f32, f32),
6717        px: f32,
6718        target: (f32, f32),
6719        color: [f32; 4],
6720    ) -> SlugDrawKey {
6721        let linear = transform.linear();
6722        SlugDrawKey {
6723            cache_key: cache_key.clone(),
6724            fill,
6725            stroke: stroke_key.cloned(),
6726            transform: [
6727                linear[0].to_bits(),
6728                linear[1].to_bits(),
6729                linear[2].to_bits(),
6730                linear[3].to_bits(),
6731                transform.scale_x.to_bits(),
6732                transform.scale_y.to_bits(),
6733                transform.translate_x.to_bits(),
6734                transform.translate_y.to_bits(),
6735                transform.rotate.to_bits(),
6736                transform.shear_x.to_bits(),
6737                transform.shear_y.to_bits(),
6738            ],
6739            origin: [origin.0.to_bits(), origin.1.to_bits()],
6740            px_bits: px.to_bits(),
6741            target: [target.0.to_bits(), target.1.to_bits()],
6742            color: color.map(f32::to_bits),
6743        }
6744    }
6745
6746    fn evict_slug_draw_cache(&mut self) -> bool {
6747        let victim = self
6748            .slug_draw_cache
6749            .iter()
6750            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
6751            .min_by_key(|(_, entry)| entry.last_touch_frame)
6752            .map(|(key, _)| key.clone());
6753        let Some(victim) = victim else {
6754            return false;
6755        };
6756        let Some(entry) = self.slug_draw_cache.remove(&victim) else {
6757            return false;
6758        };
6759        self.slug_draw_cache_bytes_total =
6760            self.slug_draw_cache_bytes_total.saturating_sub(entry.bytes);
6761        true
6762    }
6763
6764    fn reserve_slug_draw_cache(&mut self, bytes: u64) -> bool {
6765        while self.slug_draw_cache.len() >= MAX_SLUG_DRAW_CACHE_ENTRIES
6766            || self.slug_draw_cache_bytes_total.saturating_add(bytes) > MAX_SLUG_DRAW_CACHE_BYTES
6767        {
6768            if !self.evict_slug_draw_cache() {
6769                return false;
6770            }
6771        }
6772        true
6773    }
6774
6775    fn get_cached_slug_vertices(&mut self, key: &SlugDrawKey) -> Option<Arc<[slug::TessVertex]>> {
6776        let touch = self.next_cache_touch();
6777        self.slug_draw_cache.get_mut(key).map(|entry| {
6778            entry.last_used_frame = self.frame_index;
6779            entry.last_touch_frame = touch;
6780            entry.vertices.clone()
6781        })
6782    }
6783
6784    fn cached_slug_vertices(
6785        &mut self,
6786        key: SlugDrawKey,
6787        vertices: Vec<slug::TessVertex>,
6788    ) -> Arc<[slug::TessVertex]> {
6789        let touch = self.next_cache_touch();
6790        if let Some(entry) = self.slug_draw_cache.get_mut(&key) {
6791            entry.last_used_frame = self.frame_index;
6792            entry.last_touch_frame = touch;
6793            return entry.vertices.clone();
6794        }
6795        let bytes = slug_draw_cache_entry_bytes(vertices.len());
6796        if bytes > MAX_SLUG_DRAW_CACHE_BYTES || !self.reserve_slug_draw_cache(bytes) {
6797            return Arc::from(vertices);
6798        }
6799        let touch = self.next_cache_touch();
6800        let vertices: Arc<[slug::TessVertex]> = Arc::from(vertices);
6801        self.slug_draw_cache.insert(
6802            key,
6803            SlugDrawCacheEntry {
6804                vertices: vertices.clone(),
6805                bytes,
6806                last_used_frame: self.frame_index,
6807                last_touch_frame: touch,
6808            },
6809        );
6810        self.slug_draw_cache_bytes_total = self.slug_draw_cache_bytes_total.saturating_add(bytes);
6811        vertices
6812    }
6813
6814    fn cached_glyph_outline(
6815        &mut self,
6816        cache_key: &repose_text::CacheKey,
6817    ) -> Option<Arc<[repose_text::Command]>> {
6818        let touch = self.next_cache_touch();
6819        if let Some(entry) = self.glyph_outline_cache.get_mut(cache_key) {
6820            entry.last_touch = touch;
6821            return entry.commands.clone();
6822        }
6823        let commands = repose_text::extract_outline_commands(cache_key).map(Arc::from);
6824        if self.glyph_outline_cache.len() >= MAX_GLYPH_OUTLINE_CACHE_ENTRIES {
6825            self.trim_glyph_outline_cache();
6826        }
6827        self.glyph_outline_cache.insert(
6828            cache_key.clone(),
6829            GlyphOutlineEntry {
6830                commands: commands.clone(),
6831                last_touch: touch,
6832            },
6833        );
6834        commands
6835    }
6836
6837    /// Drop the coldest outlines so a working set larger than the cap degrades
6838    /// into steady churn instead of a full rebuild on every miss.
6839    fn trim_glyph_outline_cache(&mut self) {
6840        let target = (MAX_GLYPH_OUTLINE_CACHE_ENTRIES / ATLAS_COMPACT_KEEP_DEN as usize)
6841            * ATLAS_COMPACT_KEEP_NUM as usize;
6842        let mut victims: Vec<(u64, repose_text::CacheKey)> = self
6843            .glyph_outline_cache
6844            .iter()
6845            .map(|(key, entry)| (entry.last_touch, key.clone()))
6846            .collect();
6847        victims.sort_unstable_by_key(|victim| victim.0);
6848        for (_, key) in victims {
6849            if self.glyph_outline_cache.len() <= target {
6850                break;
6851            }
6852            self.glyph_outline_cache.remove(&key);
6853        }
6854    }
6855
6856    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
6857        self.atlas_mask_bind.clone()
6858    }
6859
6860    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
6861        self.atlas_color_bind.clone()
6862    }
6863
6864    fn mark_atlas_mask_failure(&mut self, key: repose_text::CacheKey) {
6865        if self.atlas_mask_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6866            self.atlas_mask_failures.clear();
6867        }
6868        self.atlas_mask_failures.insert(key);
6869    }
6870
6871    fn mark_atlas_color_failure(&mut self, key: repose_text::CacheKey) {
6872        if self.atlas_color_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6873            self.atlas_color_failures.clear();
6874        }
6875        self.atlas_color_failures.insert(key);
6876    }
6877
6878    fn upload_glyph_mask(
6879        &mut self,
6880        cache_key: repose_text::CacheKey,
6881        px: f32,
6882    ) -> Option<GlyphInfo> {
6883        let px = quantize_glyph_px(px);
6884        let keyp = repose_text::CacheKey {
6885            font_size_bits: px.to_bits(),
6886            ..cache_key
6887        };
6888        let touch = self.next_cache_touch();
6889        if let Some(info) = self.atlas_mask.map.get_mut(&keyp) {
6890            info.last_touch = touch;
6891            info.last_used_frame = self.frame_index;
6892            return Some(*info);
6893        }
6894        if self.atlas_mask_failures.contains(&keyp) || self.atlas_mask_deferred.contains(&keyp) {
6895            return None;
6896        }
6897
6898        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
6899            self.mark_atlas_mask_failure(keyp);
6900            return None;
6901        };
6902        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
6903            self.mark_atlas_mask_failure(keyp);
6904            return None;
6905        }
6906        let Some(coverage) = swash_to_a8_coverage(gb.content, &gb.data) else {
6907            self.mark_atlas_mask_failure(keyp);
6908            return None;
6909        };
6910
6911        let w = gb.w.max(1);
6912        let h = gb.h.max(1);
6913        let expected = checked_image_bytes_usize(w, h, 1).ok()?;
6914        if coverage.len() < expected {
6915            self.mark_atlas_mask_failure(keyp);
6916            return None;
6917        }
6918        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
6919            self.mark_atlas_mask_failure(keyp);
6920            return None;
6921        }
6922        if !self.alloc_or_schedule_mask(w, h) {
6923            defer_atlas_glyph(&mut self.atlas_mask_deferred, keyp);
6924            return None;
6925        }
6926        let x = self.atlas_mask.next_x;
6927        let y = self.atlas_mask.next_y;
6928        self.atlas_mask.next_x += w + 1;
6929        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
6930
6931        let layout = wgpu::TexelCopyBufferLayout {
6932            offset: 0,
6933            bytes_per_row: Some(w),
6934            rows_per_image: Some(h),
6935        };
6936        let size = wgpu::Extent3d {
6937            width: w,
6938            height: h,
6939            depth_or_array_layers: 1,
6940        };
6941        self.queue.write_texture(
6942            wgpu::TexelCopyTextureInfoBase {
6943                texture: &self.atlas_mask.tex,
6944                mip_level: 0,
6945                origin: wgpu::Origin3d { x, y, z: 0 },
6946                aspect: wgpu::TextureAspect::All,
6947            },
6948            &coverage,
6949            layout,
6950            size,
6951        );
6952
6953        let info = GlyphInfo {
6954            u0: x as f32 / self.atlas_mask.size as f32,
6955            v0: y as f32 / self.atlas_mask.size as f32,
6956            u1: (x + w) as f32 / self.atlas_mask.size as f32,
6957            v1: (y + h) as f32 / self.atlas_mask.size as f32,
6958            w: w as f32,
6959            h: h as f32,
6960            last_touch: touch,
6961            last_used_frame: self.frame_index,
6962        };
6963        self.atlas_mask.used_area = self
6964            .atlas_mask
6965            .used_area
6966            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
6967        self.atlas_mask.map.insert(keyp, info);
6968        Some(info)
6969    }
6970
6971    fn upload_glyph_color(
6972        &mut self,
6973        cache_key: repose_text::CacheKey,
6974        px: f32,
6975    ) -> Option<GlyphInfo> {
6976        let px = quantize_glyph_px(px);
6977        let keyp = repose_text::CacheKey {
6978            font_size_bits: px.to_bits(),
6979            ..cache_key
6980        };
6981        let touch = self.next_cache_touch();
6982        if let Some(info) = self.atlas_color.map.get_mut(&keyp) {
6983            info.last_touch = touch;
6984            info.last_used_frame = self.frame_index;
6985            return Some(*info);
6986        }
6987        if self.atlas_color_failures.contains(&keyp) || self.atlas_color_deferred.contains(&keyp) {
6988            return None;
6989        }
6990        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
6991            self.mark_atlas_color_failure(keyp);
6992            return None;
6993        };
6994        if !matches!(gb.content, repose_text::SwashContent::Color) {
6995            self.mark_atlas_color_failure(keyp);
6996            return None;
6997        }
6998        let w = gb.w.max(1);
6999        let h = gb.h.max(1);
7000        let expected = checked_image_bytes_usize(w, h, 4).ok()?;
7001        if gb.data.len() < expected {
7002            self.mark_atlas_color_failure(keyp);
7003            return None;
7004        }
7005        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7006            self.mark_atlas_color_failure(keyp);
7007            return None;
7008        }
7009        if !self.alloc_or_schedule_color(w, h) {
7010            defer_atlas_glyph(&mut self.atlas_color_deferred, keyp);
7011            return None;
7012        }
7013        let x = self.atlas_color.next_x;
7014        let y = self.atlas_color.next_y;
7015        self.atlas_color.next_x += w + 1;
7016        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
7017
7018        let layout = wgpu::TexelCopyBufferLayout {
7019            offset: 0,
7020            bytes_per_row: Some(w * 4),
7021            rows_per_image: Some(h),
7022        };
7023        let size = wgpu::Extent3d {
7024            width: w,
7025            height: h,
7026            depth_or_array_layers: 1,
7027        };
7028        self.queue.write_texture(
7029            wgpu::TexelCopyTextureInfoBase {
7030                texture: &self.atlas_color.tex,
7031                mip_level: 0,
7032                origin: wgpu::Origin3d { x, y, z: 0 },
7033                aspect: wgpu::TextureAspect::All,
7034            },
7035            &gb.data,
7036            layout,
7037            size,
7038        );
7039        let info = GlyphInfo {
7040            u0: x as f32 / self.atlas_color.size as f32,
7041            v0: y as f32 / self.atlas_color.size as f32,
7042            u1: (x + w) as f32 / self.atlas_color.size as f32,
7043            v1: (y + h) as f32 / self.atlas_color.size as f32,
7044            w: w as f32,
7045            h: h as f32,
7046            last_touch: touch,
7047            last_used_frame: self.frame_index,
7048        };
7049        self.atlas_color.used_area = self
7050            .atlas_color
7051            .used_area
7052            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7053        self.atlas_color.map.insert(keyp, info);
7054        Some(info)
7055    }
7056
7057    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
7058        let Some(x_end) = self
7059            .atlas_mask
7060            .next_x
7061            .checked_add(w)
7062            .and_then(|v| v.checked_add(1))
7063        else {
7064            return false;
7065        };
7066        if x_end >= self.atlas_mask.size {
7067            self.atlas_mask.next_x = 1;
7068            let Some(next_y) = self
7069                .atlas_mask
7070                .next_y
7071                .checked_add(self.atlas_mask.row_h)
7072                .and_then(|v| v.checked_add(1))
7073            else {
7074                return false;
7075            };
7076            self.atlas_mask.next_y = next_y;
7077            self.atlas_mask.row_h = 0;
7078        }
7079        let Some(y_end) = self
7080            .atlas_mask
7081            .next_y
7082            .checked_add(h)
7083            .and_then(|v| v.checked_add(1))
7084        else {
7085            return false;
7086        };
7087        y_end < self.atlas_mask.size
7088    }
7089
7090    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
7091        let Some(x_end) = self
7092            .atlas_color
7093            .next_x
7094            .checked_add(w)
7095            .and_then(|v| v.checked_add(1))
7096        else {
7097            return false;
7098        };
7099        if x_end >= self.atlas_color.size {
7100            self.atlas_color.next_x = 1;
7101            let Some(next_y) = self
7102                .atlas_color
7103                .next_y
7104                .checked_add(self.atlas_color.row_h)
7105                .and_then(|v| v.checked_add(1))
7106            else {
7107                return false;
7108            };
7109            self.atlas_color.next_y = next_y;
7110            self.atlas_color.row_h = 0;
7111        }
7112        let Some(y_end) = self
7113            .atlas_color
7114            .next_y
7115            .checked_add(h)
7116            .and_then(|v| v.checked_add(1))
7117        else {
7118            return false;
7119        };
7120        y_end < self.atlas_color.size
7121    }
7122
7123    /// Place a `w` x `h` cell in the mask atlas, or schedule the work needed to
7124    /// make room. Growth and eviction both re-pack the atlas, which would move
7125    /// glyphs already batched into the frame in flight, so they are deferred to
7126    /// the next `begin_frame`; the caller treats a `false` as "skip this glyph".
7127    fn alloc_or_schedule_mask(&mut self, w: u32, h: u32) -> bool {
7128        if self.atlas_mask.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7129            || self
7130                .atlas_mask
7131                .used_area
7132                .saturating_add(u64::from(w) * u64::from(h))
7133                >= self.atlas_mask.size as u64 * self.atlas_mask.size as u64
7134        {
7135            self.atlas_mask_pending.compact = true;
7136            return false;
7137        }
7138        if self.alloc_space_mask(w, h) {
7139            return true;
7140        }
7141        if self.atlas_mask.size < MAX_GLYPH_ATLAS_SIZE {
7142            self.atlas_mask_pending.grow = true;
7143        } else {
7144            self.atlas_mask_pending.compact = true;
7145        }
7146        false
7147    }
7148
7149    /// See [`Self::alloc_or_schedule_mask`].
7150    fn alloc_or_schedule_color(&mut self, w: u32, h: u32) -> bool {
7151        if self.atlas_color.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7152            || self
7153                .atlas_color
7154                .used_area
7155                .saturating_add(u64::from(w) * u64::from(h))
7156                >= self.atlas_color.size as u64 * self.atlas_color.size as u64
7157        {
7158            self.atlas_color_pending.compact = true;
7159            return false;
7160        }
7161        if self.alloc_space_color(w, h) {
7162            return true;
7163        }
7164        if self.atlas_color.size < MAX_GLYPH_ATLAS_SIZE {
7165            self.atlas_color_pending.grow = true;
7166        } else {
7167            self.atlas_color_pending.compact = true;
7168        }
7169        false
7170    }
7171}
7172
7173/// Pack the shape brush enums into the single `bits` word the shaders read, so
7174/// they cost one flat varying instead of three locations.
7175fn pack_shape_bits(brush_type: u32, grad_kind: u32, tile_mode: u32, cap: u32) -> u32 {
7176    debug_assert!(brush_type < 4 && grad_kind < 4 && tile_mode < 4 && cap < 4);
7177    (brush_type & 3) | ((grad_kind & 3) << 2) | ((tile_mode & 3) << 4) | ((cap & 3) << 6)
7178}
7179
7180/// Packed brush fields shared by the shape instances (border, ellipse,
7181/// ellipse border, arc). Gradient endpoints are shape-local px; the shaders
7182/// recenter `(0,0)` at the shape top-left. Radial packs `center` into
7183/// `grad_p0` and `radius` into `grad_p1.x`; sweep packs `center` into
7184/// `grad_p0`.
7185///
7186/// `rect` is the shape's scene-space bounds and `transform` the accumulated
7187/// scene transform.
7188/// Endpoints are converted from shape-local px through the inverse linear
7189/// part so rotation and uniform scale cancel against the shader's
7190/// un-rotation. Non-uniform scale and shear distort the gradient the same
7191/// way they distort the shape (the shader un-rotates but cannot un-scale
7192/// pixels).
7193fn brush_to_shape_fields(
7194    brush: &Brush,
7195    rect: &repose_core::Rect,
7196    transform: &Transform,
7197) -> (u32, u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2], u32) {
7198    let to_local = |p: Vec2| {
7199        let m = transform.linear();
7200        let det = m[0] * m[3] - m[1] * m[2];
7201        if det.abs() < 1e-12 {
7202            return [p.x, p.y];
7203        }
7204        [
7205            (m[3] * p.x - m[1] * p.y) / det,
7206            (-m[2] * p.x + m[0] * p.y) / det,
7207        ]
7208    };
7209    match brush {
7210        Brush::Solid(c) => (
7211            0u32,
7212            0u32,
7213            c.to_linear(),
7214            [0.0; 4],
7215            [0.0; 2],
7216            [0.0; 2],
7217            0u32,
7218        ),
7219        Brush::Linear {
7220            start,
7221            end,
7222            start_color,
7223            end_color,
7224        } => (
7225            1u32,
7226            0u32,
7227            start_color.to_linear(),
7228            end_color.to_linear(),
7229            to_local(*start),
7230            to_local(*end),
7231            0u32,
7232        ),
7233        Brush::LinearNormalized {
7234            start,
7235            end,
7236            start_color,
7237            end_color,
7238        } => (
7239            1u32,
7240            0u32,
7241            start_color.to_linear(),
7242            end_color.to_linear(),
7243            to_local(Vec2 {
7244                x: start.x * rect.w,
7245                y: start.y * rect.h,
7246            }),
7247            to_local(Vec2 {
7248                x: end.x * rect.w,
7249                y: end.y * rect.h,
7250            }),
7251            0u32,
7252        ),
7253        Brush::Radial {
7254            center,
7255            radius,
7256            start_color,
7257            end_color,
7258        } => (
7259            1u32,
7260            1u32,
7261            start_color.to_linear(),
7262            end_color.to_linear(),
7263            to_local(*center),
7264            [radius.max(0.0), 0.0],
7265            0u32,
7266        ),
7267        Brush::Sweep {
7268            center,
7269            start_color,
7270            end_color,
7271        } => (
7272            1u32,
7273            2u32,
7274            start_color.to_linear(),
7275            end_color.to_linear(),
7276            to_local(*center),
7277            [0.0, 0.0],
7278            0u32,
7279        ),
7280        _ => (0u32, 0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2], 0u32),
7281    }
7282}
7283
7284fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
7285    match brush {
7286        Brush::Solid(c) => (
7287            0u32,
7288            c.to_linear(),
7289            [0.0, 0.0, 0.0, 0.0],
7290            [0.0, 0.0],
7291            [0.0, 1.0],
7292        ),
7293        Brush::Linear {
7294            start,
7295            end,
7296            start_color,
7297            end_color,
7298        } => (
7299            1u32,
7300            start_color.to_linear(),
7301            end_color.to_linear(),
7302            [start.x, start.y],
7303            [end.x, end.y],
7304        ),
7305        Brush::LinearNormalized {
7306            start,
7307            end,
7308            start_color,
7309            end_color,
7310        } => (
7311            1u32,
7312            start_color.to_linear(),
7313            end_color.to_linear(),
7314            [start.x, start.y],
7315            [end.x, end.y],
7316        ),
7317        Brush::Radial { start_color, .. } => (
7318            0u32,
7319            start_color.to_linear(),
7320            [0.0, 0.0, 0.0, 0.0],
7321            [0.0, 0.0],
7322            [0.0, 1.0],
7323        ),
7324        Brush::Sweep { start_color, .. } => (
7325            0u32,
7326            start_color.to_linear(),
7327            [0.0, 0.0, 0.0, 0.0],
7328            [0.0, 0.0],
7329            [0.0, 1.0],
7330        ),
7331        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
7332    }
7333}
7334
7335/// Fallback color when a [`Brush`] reaches a solid-only path (glyph atlas
7336/// uploads for gradient text). Uses the gradient's start color.
7337#[allow(dead_code)]
7338fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
7339    match brush {
7340        Brush::Solid(c) => c.to_linear(),
7341        Brush::Linear { start_color, .. } => start_color.to_linear(),
7342        Brush::LinearNormalized { start_color, .. } => start_color.to_linear(),
7343        Brush::Radial { start_color, .. } => start_color.to_linear(),
7344        Brush::Sweep { start_color, .. } => start_color.to_linear(),
7345        _ => [0.0; 4],
7346    }
7347}
7348
7349fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
7350    let size = 1024u32;
7351    let tex = device.create_texture(&wgpu::TextureDescriptor {
7352        label: Some("glyph atlas A8"),
7353        size: wgpu::Extent3d {
7354            width: size,
7355            height: size,
7356            depth_or_array_layers: 1,
7357        },
7358        mip_level_count: 1,
7359        sample_count: 1,
7360        dimension: wgpu::TextureDimension::D2,
7361        format: wgpu::TextureFormat::R8Unorm,
7362        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7363        view_formats: &[],
7364    });
7365    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7366    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7367        label: Some("glyph atlas sampler A8"),
7368        address_mode_u: wgpu::AddressMode::ClampToEdge,
7369        address_mode_v: wgpu::AddressMode::ClampToEdge,
7370        address_mode_w: wgpu::AddressMode::ClampToEdge,
7371        mag_filter: wgpu::FilterMode::Linear,
7372        min_filter: wgpu::FilterMode::Linear,
7373        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7374        ..Default::default()
7375    });
7376
7377    AtlasA8 {
7378        tex,
7379        view,
7380        sampler,
7381        size,
7382        next_x: 1,
7383        next_y: 1,
7384        row_h: 0,
7385        map: HashMap::new(),
7386        used_area: 0,
7387    }
7388}
7389
7390fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
7391    let size = 1024u32;
7392    let tex = device.create_texture(&wgpu::TextureDescriptor {
7393        label: Some("glyph atlas RGBA"),
7394        size: wgpu::Extent3d {
7395            width: size,
7396            height: size,
7397            depth_or_array_layers: 1,
7398        },
7399        mip_level_count: 1,
7400        sample_count: 1,
7401        dimension: wgpu::TextureDimension::D2,
7402        format: wgpu::TextureFormat::Rgba8UnormSrgb,
7403        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7404        view_formats: &[],
7405    });
7406    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7407    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7408        label: Some("glyph atlas sampler RGBA"),
7409        address_mode_u: wgpu::AddressMode::ClampToEdge,
7410        address_mode_v: wgpu::AddressMode::ClampToEdge,
7411        address_mode_w: wgpu::AddressMode::ClampToEdge,
7412        mag_filter: wgpu::FilterMode::Linear,
7413        min_filter: wgpu::FilterMode::Linear,
7414        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7415        ..Default::default()
7416    });
7417    AtlasRGBA {
7418        tex,
7419        view,
7420        sampler,
7421        size,
7422        next_x: 1,
7423        next_y: 1,
7424        row_h: 0,
7425        map: HashMap::new(),
7426        used_area: 0,
7427    }
7428}
7429
7430#[cfg(feature = "winit-surface")]
7431impl WgpuSurfaceBackend {
7432    /// Drop the surface, keeping device/queue/pipelines. Releases the old
7433    /// native-window binding. Call `recreate_surface` to present again.
7434    pub fn take_surface(&mut self) -> Option<wgpu::Surface<'static>> {
7435        self.surface.take()
7436    }
7437
7438    /// Create a fresh surface for `window` on the retained instance and
7439    /// configure it. Recovers from `CurrentSurfaceTexture::Lost`, where
7440    /// reconfiguring the old surface object cannot help.
7441    pub fn recreate_surface(&mut self, window: &Arc<winit::window::Window>) -> anyhow::Result<()> {
7442        self.window = Some(window.clone());
7443        let size = window.inner_size();
7444        if size.width == 0 || size.height == 0 {
7445            self.surface = None;
7446            self.pending_reconfigure = false;
7447            self.renderer.resize(0, 0);
7448            anyhow::bail!("window has zero size; surface recreation deferred");
7449        }
7450        let Some(instance) = self.instance.as_ref() else {
7451            anyhow::bail!("no wgpu instance retained; cannot recreate surface")
7452        };
7453        let Some(config) = self.surface_config.as_mut() else {
7454            anyhow::bail!("no surface config retained; cannot recreate surface")
7455        };
7456        let (width, height) = clamp_output_size(
7457            size.width,
7458            size.height,
7459            self.renderer.device.limits().max_texture_dimension_2d,
7460        );
7461        config.width = width;
7462        config.height = height;
7463        let surface = instance.create_surface(window.clone())?;
7464        surface.configure(&self.renderer.device, config);
7465        self.surface = Some(surface);
7466        self.pending_reconfigure = false;
7467        self.renderer.resize(width, height);
7468        Ok(())
7469    }
7470}
7471
7472#[cfg(feature = "winit-surface")]
7473impl RenderBackend for WgpuSurfaceBackend {
7474    fn configure_surface(&mut self, width: u32, height: u32) -> (u32, u32) {
7475        if width == 0 || height == 0 {
7476            self.renderer.resize(0, 0);
7477            self.pending_reconfigure = true;
7478            return (0, 0);
7479        }
7480        let (width, height) = clamp_output_size(
7481            width,
7482            height,
7483            self.renderer.device.limits().max_texture_dimension_2d,
7484        );
7485        if self.renderer.output_width == width && self.renderer.output_height == height {
7486            if let Some(ref mut config) = self.surface_config {
7487                config.width = width;
7488                config.height = height;
7489            }
7490            return (width, height);
7491        }
7492        if let Some(ref mut config) = self.surface_config {
7493            config.width = width;
7494            config.height = height;
7495        }
7496        self.renderer.resize(width, height);
7497        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref())
7498        {
7499            surface.configure(&self.renderer.device, config);
7500        }
7501        (width, height)
7502    }
7503
7504    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) -> bool {
7505        if self.pending_reconfigure {
7506            if let (Some(surface), Some(config)) =
7507                (self.surface.as_ref(), self.surface_config.as_ref())
7508            {
7509                surface.configure(&self.renderer.device, config);
7510            }
7511            self.pending_reconfigure = false;
7512        }
7513
7514        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
7515            request_frame();
7516            return false;
7517        }
7518        if let Err(error) = self.renderer.begin_frame() {
7519            log::warn!("begin renderer frame: {error:#}");
7520            request_frame();
7521            return false;
7522        }
7523
7524        let Some(surface) = self.surface.as_ref() else {
7525            self.renderer.end_frame();
7526            request_frame();
7527            return false;
7528        };
7529        let frame = match surface.get_current_texture() {
7530            wgpu::CurrentSurfaceTexture::Success(f) => f,
7531            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
7532                self.pending_reconfigure = true;
7533                f
7534            }
7535            other @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost) => {
7536                self.surface = None;
7537                let result = self
7538                    .window
7539                    .clone()
7540                    .ok_or_else(|| anyhow::anyhow!("no window retained for surface recreation"))
7541                    .and_then(|window| self.recreate_surface(&window));
7542                if let Err(error) = result {
7543                    log::warn!("surface {other:?}; recreation deferred: {error:#}");
7544                }
7545                self.renderer.end_frame();
7546                request_frame();
7547                return false;
7548            }
7549            other => {
7550                match other {
7551                    wgpu::CurrentSurfaceTexture::Validation => {
7552                        log::warn!("surface {other:?}; reconfiguring next frame");
7553                        self.pending_reconfigure = true;
7554                    }
7555                    wgpu::CurrentSurfaceTexture::Timeout
7556                    | wgpu::CurrentSurfaceTexture::Occluded => {
7557                        log::debug!("surface {other:?}; retrying next frame");
7558                    }
7559                    _ => {}
7560                }
7561                self.renderer.end_frame();
7562                request_frame();
7563                return false;
7564            }
7565        };
7566
7567        // The swapchain extent and the renderer's size are updated from
7568        // separate events, so they can disagree for a frame mid-resize (most
7569        // visibly when dragging a window into a portrait shape). With MSAA on,
7570        // a color attachment whose extent differs from its resolve target
7571        // fails wgpu validation, which drops the whole pass *including its
7572        // clear*, so the just-reconfigured swapchain keeps undefined contents
7573        // and presents as green/black streaks. Adopt the swapchain's size and
7574        // retry next frame instead of rendering an invalid pass.
7575        let (swap_w, swap_h) = (frame.texture.width(), frame.texture.height());
7576        if swap_w != self.renderer.output_width || swap_h != self.renderer.output_height {
7577            log::debug!(
7578                "swapchain {swap_w}x{swap_h} != renderer {}x{}; resyncing",
7579                self.renderer.output_width,
7580                self.renderer.output_height
7581            );
7582            drop(frame);
7583            if let Some(config) = self.surface_config.as_mut() {
7584                config.width = swap_w;
7585                config.height = swap_h;
7586            }
7587            self.renderer.resize(swap_w, swap_h);
7588            self.pending_reconfigure = true;
7589            self.renderer.end_frame();
7590            request_frame();
7591            return false;
7592        }
7593
7594        let swap_view = if let Some(view_format) = self
7595            .surface_config
7596            .as_ref()
7597            .and_then(|c| c.view_formats.iter().find(|f| f.is_srgb()).copied())
7598        {
7599            frame.texture.create_view(&wgpu::TextureViewDescriptor {
7600                format: Some(view_format),
7601                ..Default::default()
7602            })
7603        } else {
7604            frame
7605                .texture
7606                .create_view(&wgpu::TextureViewDescriptor::default())
7607        };
7608        let mut encoder =
7609            self.renderer
7610                .device
7611                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7612                    label: Some("frame encoder"),
7613                });
7614
7615        self.renderer.render_scene_to_encoder_with_texture(
7616            scene,
7617            &mut encoder,
7618            &swap_view,
7619            Some(&frame.texture),
7620            None,
7621        );
7622
7623        //NOTE: The WebGL HAL present path (fullscreen triangle / blit) does not
7624        // restore gl.colorMask. Hence this is needed to prevent frames from going transparent.
7625        {
7626            let _reset = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
7627                label: Some("webgl color_mask reset before present"),
7628                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7629                    view: &swap_view,
7630                    resolve_target: None,
7631                    ops: wgpu::Operations {
7632                        load: wgpu::LoadOp::Load,
7633                        store: wgpu::StoreOp::Store,
7634                    },
7635                    depth_slice: None,
7636                })],
7637                depth_stencil_attachment: None,
7638                timestamp_writes: None,
7639                occlusion_query_set: None,
7640                multiview_mask: None,
7641            });
7642        }
7643
7644        self.renderer
7645            .queue
7646            .submit(std::iter::once(encoder.finish()));
7647        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
7648            log::warn!("queue.present panicked: {:?}", e);
7649            self.renderer.end_frame();
7650            request_frame();
7651            return false;
7652        }
7653        self.renderer.end_frame();
7654        true
7655    }
7656}
7657
7658impl WgpuSceneRenderer {
7659    /// Open a translator-owned flatten layer for a perspective `PushTransform`.
7660    ///
7661    /// True perspective cannot ride the affine instance fast path, so the
7662    /// subtree renders flat into an offscreen layer and is composited back
7663    /// projectively on the matching pop (CSS-style flattening). The layer
7664    /// rect is the currently visible scissor in this target: content outside
7665    /// it is invisible in the parent, so clipping it in the layer changes
7666    /// nothing. Children keep the node's affine part on the stack (so
7667    /// `combine` stays affine-only) plus a layer-local shift, exactly like
7668    /// producer-owned blur layers - which is exact under rigid ancestors
7669    /// (translations commute) and the documented layer contract otherwise.
7670    #[allow(clippy::too_many_arguments)]
7671    fn replay_active_clips(
7672        &mut self,
7673        clips: &[ActiveClip],
7674        parent_transform: &Transform,
7675        origin: (f32, f32),
7676        target_size: (f32, f32),
7677        pass: &mut Pass,
7678        encoder: &mut wgpu::CommandEncoder,
7679    ) -> Vec<ActiveClip> {
7680        let mut current = repose_core::Rect {
7681            x: 0.0,
7682            y: 0.0,
7683            w: target_size.0,
7684            h: target_size.1,
7685        };
7686        let mut replayed = Vec::with_capacity(clips.len());
7687        for (index, clip) in clips.iter().enumerate() {
7688            let has_inverse = clips[..index].iter().any(ActiveClip::difference);
7689            let mut blocked = clip.blocked();
7690            if has_inverse && !blocked {
7691                log::error!(
7692                    "unsupported clip nesting: a Difference clip cannot contain another clip"
7693                );
7694                blocked = true;
7695            }
7696            let difference = clip.difference();
7697            let mut command = None;
7698            match clip {
7699                ActiveClip::Rect {
7700                    off: _,
7701                    cnt: _,
7702                    rect,
7703                    radii,
7704                    ..
7705                } => {
7706                    let local_rect = parent_rect_to_layer_rect(*rect, parent_transform, origin)
7707                        .unwrap_or(repose_core::Rect {
7708                            x: 0.0,
7709                            y: 0.0,
7710                            w: 0.0,
7711                            h: 0.0,
7712                        });
7713                    let local_radii =
7714                        parent_radii_to_layer_radii(*radii, parent_transform).unwrap_or([0.0; 4]);
7715                    let next = if difference {
7716                        current
7717                    } else {
7718                        intersect(current, local_rect)
7719                    };
7720                    let scissor = rect_to_scissor(
7721                        next,
7722                        target_size.0.max(1.0) as u32,
7723                        target_size.1.max(1.0) as u32,
7724                    );
7725                    let has_area = local_rect.x.is_finite()
7726                        && local_rect.y.is_finite()
7727                        && local_rect.w.is_finite()
7728                        && local_rect.h.is_finite()
7729                        && local_rect.w > 0.0
7730                        && local_rect.h > 0.0;
7731                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7732                        blocked = blocked || !difference;
7733                    } else {
7734                        let ndc = rect_to_ndc(local_rect, target_size.0, target_size.1);
7735                        let instance = ClipInstance {
7736                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
7737                            radii: local_radii,
7738                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
7739                        };
7740                        let bytes = bytemuck::bytes_of(&instance);
7741                        if self
7742                            .clip_ring
7743                            .grow_to_fit(
7744                                &self.device,
7745                                encoder,
7746                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
7747                            )
7748                            .is_ok()
7749                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
7750                        {
7751                            command = Some(Cmd::ClipPush {
7752                                off: new_off,
7753                                cnt: 1,
7754                                scissor,
7755                                difference,
7756                                applied: true,
7757                            });
7758                            replayed.push(ActiveClip::Rect {
7759                                off: new_off,
7760                                cnt: 1,
7761                                rect: local_rect,
7762                                radii: local_radii,
7763                                scissor,
7764                                difference,
7765                                applied: true,
7766                                blocked: false,
7767                            });
7768                        } else {
7769                            blocked = blocked || !difference;
7770                        }
7771                    }
7772                    current = next;
7773                }
7774                ActiveClip::Vector {
7775                    mesh,
7776                    source,
7777                    affine,
7778                    ..
7779                } => {
7780                    let local_affine =
7781                        parent_affine_to_layer_affine(*affine, parent_transform, origin)
7782                            .unwrap_or([0.0; 6]);
7783                    let aabb = mesh_aabb(source, local_affine);
7784                    let next = if difference {
7785                        current
7786                    } else {
7787                        intersect(current, aabb)
7788                    };
7789                    let scissor = rect_to_scissor(
7790                        next,
7791                        target_size.0.max(1.0) as u32,
7792                        target_size.1.max(1.0) as u32,
7793                    );
7794                    let has_area = aabb.x.is_finite()
7795                        && aabb.y.is_finite()
7796                        && aabb.w.is_finite()
7797                        && aabb.h.is_finite()
7798                        && aabb.w > 0.0
7799                        && aabb.h > 0.0
7800                        && mesh
7801                            .as_ref()
7802                            .is_some_and(|mesh| mesh.vertex_count > 0 && mesh.index_count > 0);
7803                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7804                        blocked = blocked || !difference;
7805                    } else if let Some(mesh_gpu) = mesh.clone()
7806                        && let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
7807                            local_affine,
7808                            &repose_core::PaintDesc::Solid,
7809                        ))
7810                    {
7811                        command = Some(Cmd::VectorClipPush {
7812                            mesh: mesh_gpu.clone(),
7813                            uoff,
7814                            scissor,
7815                            difference,
7816                            applied: true,
7817                        });
7818                        replayed.push(ActiveClip::Vector {
7819                            mesh: Some(mesh_gpu),
7820                            source: source.clone(),
7821                            uoff,
7822                            affine: local_affine,
7823                            scissor,
7824                            difference,
7825                            applied: true,
7826                            blocked: false,
7827                        });
7828                    } else {
7829                        blocked = blocked || !difference;
7830                    }
7831                    current = next;
7832                }
7833            }
7834            if blocked {
7835                current = repose_core::Rect {
7836                    x: 0.0,
7837                    y: 0.0,
7838                    w: 0.0,
7839                    h: 0.0,
7840                };
7841            }
7842            if command.is_none() {
7843                replayed.push(match clip {
7844                    ActiveClip::Rect {
7845                        rect,
7846                        radii,
7847                        scissor,
7848                        difference,
7849                        ..
7850                    } => ActiveClip::Rect {
7851                        off: 0,
7852                        cnt: 0,
7853                        rect: *rect,
7854                        radii: *radii,
7855                        scissor: *scissor,
7856                        difference: *difference,
7857                        applied: false,
7858                        blocked,
7859                    },
7860                    ActiveClip::Vector {
7861                        mesh,
7862                        source,
7863                        affine,
7864                        scissor,
7865                        difference,
7866                        ..
7867                    } => ActiveClip::Vector {
7868                        mesh: mesh.clone(),
7869                        source: source.clone(),
7870                        uoff: 0,
7871                        affine: parent_affine_to_layer_affine(*affine, parent_transform, origin)
7872                            .unwrap_or([0.0; 6]),
7873                        scissor: *scissor,
7874                        difference: *difference,
7875                        applied: false,
7876                        blocked,
7877                    },
7878                });
7879            }
7880            if let Some(command) = command {
7881                pass.cmds.push(command);
7882            }
7883            pass.active_scissor = if blocked {
7884                None
7885            } else {
7886                let scissor = rect_to_scissor(
7887                    current,
7888                    target_size.0.max(1.0) as u32,
7889                    target_size.1.max(1.0) as u32,
7890                );
7891                (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
7892            };
7893        }
7894        replayed
7895    }
7896
7897    #[allow(clippy::too_many_arguments)]
7898    fn push_perspective_layer(
7899        &mut self,
7900        node: Transform,
7901        top: Transform,
7902        transform_stack: &mut Vec<Transform>,
7903        scissor_stack: &mut Vec<repose_core::Rect>,
7904        root_clip_rect: &mut repose_core::Rect,
7905        current_target_size: &mut (f32, f32),
7906        current_pass: &mut Pass,
7907        active_clips: &mut Vec<ActiveClip>,
7908        passes: &mut Vec<Pass>,
7909        target_stack: &mut Vec<PassTarget>,
7910        flatten_stack: &mut Vec<FlattenRecord>,
7911        id_head: &mut u32,
7912        pass_id_head: &mut u64,
7913        ids_used: &mut Vec<u32>,
7914        encoder: &mut wgpu::CommandEncoder,
7915    ) {
7916        // Full projective map: affine ancestors over the node's map.
7917        // Ancestors are affine by construction (perspective always flattens
7918        // at push, and only stripped affines reach the stack).
7919        let map =
7920            Transform::compose_projective(&top.projective_matrix(), &node.projective_matrix());
7921        let scr = scissor_stack.last().copied().unwrap_or(*root_clip_rect);
7922        let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
7923        if !scr.w.is_finite()
7924            || !scr.h.is_finite()
7925            || scr.w < 1.0
7926            || scr.h < 1.0
7927            || scr.w > max_dimension
7928            || scr.h > max_dimension
7929        {
7930            return;
7931        }
7932        let w = scr.w.ceil();
7933        let h = scr.h.ceil();
7934        let layer_rect = repose_core::Rect {
7935            x: scr.x,
7936            y: scr.y,
7937            w,
7938            h,
7939        };
7940        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
7941            return;
7942        }
7943        let layer_id = *id_head;
7944        *id_head = id_head.wrapping_add(1);
7945        ids_used.push(layer_id);
7946
7947        let stack_len = transform_stack.len();
7948        // Children render with the ancestors' map only: the node's own
7949        // affine part lives in `map` and applies once, at composite time.
7950        // (Pushing the stripped affine here too would foreshorten twice.)
7951        transform_stack.push(top);
7952        transform_stack.push(Transform::translate(-layer_rect.x, -layer_rect.y));
7953
7954        let saved_scissor_stack = std::mem::replace(
7955            scissor_stack,
7956            vec![repose_core::Rect {
7957                x: 0.0,
7958                y: 0.0,
7959                w,
7960                h,
7961            }],
7962        );
7963        let saved_root = std::mem::replace(
7964            root_clip_rect,
7965            repose_core::Rect {
7966                x: 0.0,
7967                y: 0.0,
7968                w,
7969                h,
7970            },
7971        );
7972        let saved_size = std::mem::replace(current_target_size, (w, h));
7973        let prev_target = current_pass.target;
7974        let saved_active_scissor = current_pass.active_scissor;
7975        let saved_clips = std::mem::take(active_clips);
7976        let layer_pass_id = *pass_id_head;
7977        *pass_id_head += 1;
7978        let mut layer_pass = Pass {
7979            id: layer_pass_id,
7980            target: PassTarget::Layer(layer_id),
7981            initial_scissor: (0, 0, w as u32, h as u32),
7982            active_scissor: Some((0, 0, w as u32, h as u32)),
7983            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
7984            cmds: Vec::new(),
7985        };
7986        let layer_clips = self.replay_active_clips(
7987            &saved_clips,
7988            &top,
7989            (layer_rect.x, layer_rect.y),
7990            (w, h),
7991            &mut layer_pass,
7992            encoder,
7993        );
7994        *active_clips = layer_clips;
7995        let saved = std::mem::replace(current_pass, layer_pass);
7996        passes.push(saved);
7997        target_stack.push(prev_target);
7998        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
7999        *current_target_size = (w, h);
8000        flatten_stack.push(FlattenRecord {
8001            stack_len,
8002            layer_id,
8003            map,
8004            layer_rect,
8005            saved_scissor_stack,
8006            saved_root,
8007            saved_size,
8008            saved_active_scissor,
8009            saved_clips,
8010        });
8011    }
8012
8013    /// Close a flatten layer: restore the parent target and composite the
8014    /// layer texture through the recorded projective map.
8015    #[allow(clippy::too_many_arguments)]
8016    fn pop_perspective_layer(
8017        &mut self,
8018        rec: FlattenRecord,
8019        scissor_stack: &mut Vec<repose_core::Rect>,
8020        root_clip_rect: &mut repose_core::Rect,
8021        current_target_size: &mut (f32, f32),
8022        current_pass: &mut Pass,
8023        active_clips: &mut Vec<ActiveClip>,
8024        passes: &mut Vec<Pass>,
8025        target_stack: &mut Vec<PassTarget>,
8026        pass_id_head: &mut u64,
8027        encoder: &mut wgpu::CommandEncoder,
8028    ) {
8029        *scissor_stack = rec.saved_scissor_stack;
8030        *root_clip_rect = rec.saved_root;
8031        *current_target_size = rec.saved_size;
8032        *active_clips = rec.saved_clips;
8033        let resumed_pass_id = *pass_id_head;
8034        *pass_id_head += 1;
8035        let saved = std::mem::replace(
8036            current_pass,
8037            Pass {
8038                id: resumed_pass_id,
8039                target: target_stack.pop().unwrap_or(PassTarget::Surface),
8040                initial_scissor: rec.saved_active_scissor.unwrap_or((0, 0, 1, 1)),
8041                active_scissor: rec.saved_active_scissor,
8042                clear_color: None,
8043                cmds: Vec::new(),
8044            },
8045        );
8046        passes.push(saved);
8047
8048        // Project the layer-rect corners (parent space) to NDC in the
8049        // resumed (parent) target, keeping each corner's homogeneous w for
8050        // perspective-correct sampling.
8051        let (tw, th) = rec.saved_size;
8052        let r = rec.layer_rect;
8053        let corners = [
8054            (r.x, r.y),
8055            (r.x + r.w, r.y),
8056            (r.x + r.w, r.y + r.h),
8057            (r.x, r.y + r.h),
8058        ];
8059        let mut ndc = [[0.0f32; 2]; 4];
8060        let mut ws = [1.0f32; 4];
8061        let mut all_behind = true;
8062        for (i, (x, y)) in corners.iter().enumerate() {
8063            let w_raw = rec.map[6] * x + rec.map[7] * y + rec.map[8];
8064            let w = if w_raw.abs() < 1e-6 {
8065                if w_raw < 0.0 { -1e-6 } else { 1e-6 }
8066            } else {
8067                w_raw
8068            };
8069            if w > 0.0 {
8070                all_behind = false;
8071            }
8072            let px = (rec.map[0] * x + rec.map[1] * y + rec.map[2]) / w;
8073            let py = (rec.map[3] * x + rec.map[4] * y + rec.map[5]) / w;
8074            ndc[i] = [px / tw * 2.0 - 1.0, 1.0 - py / th * 2.0];
8075            ws[i] = w;
8076        }
8077        if all_behind {
8078            // Entire subtree behind the viewer: nothing to composite (the
8079            // layer pass still ran, but its output is correctly discarded).
8080            return;
8081        }
8082        let Some(layer) = self.layer_pool.get(&rec.layer_id) else {
8083            return;
8084        };
8085        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
8086        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
8087        let inst = ProjectiveInstance {
8088            c0: ndc[0],
8089            c1: ndc[1],
8090            c2: ndc[2],
8091            c3: ndc[3],
8092            uv: [0.0, 0.0, uv_u1, uv_v1],
8093            w: ws,
8094            alpha: 1.0,
8095            _pad: [0.0; 3],
8096        };
8097        if self
8098            .projective_ring
8099            .grow_to_fit(
8100                &self.device,
8101                encoder,
8102                std::mem::size_of::<ProjectiveInstance>() as u64,
8103            )
8104            .is_err()
8105        {
8106            return;
8107        }
8108        let bytes = bytemuck::bytes_of(&inst);
8109        let Ok(off) = self.projective_ring.alloc_write(&self.queue, bytes) else {
8110            return;
8111        };
8112        current_pass.cmds.push(Cmd::CompositeProjective {
8113            off,
8114            cnt: 1,
8115            layer_id: rec.layer_id,
8116        });
8117    }
8118
8119    fn mesh_cache_key(mesh: &Arc<repose_core::VectorMeshData>) -> MeshCacheKey {
8120        MeshCacheKey {
8121            source: Arc::as_ptr(mesh) as usize,
8122            index: 0,
8123            kind: 1,
8124            vertex_len: mesh.vertices.len(),
8125            index_len: mesh.indices.len(),
8126        }
8127    }
8128
8129    fn overlay_mesh_cache_key(
8130        meshes: &Arc<[repose_core::VectorMeshData]>,
8131        index: usize,
8132    ) -> MeshCacheKey {
8133        let mesh = &meshes[index];
8134        MeshCacheKey {
8135            source: meshes.as_ptr() as usize,
8136            index,
8137            kind: 2,
8138            vertex_len: mesh.vertices.len(),
8139            index_len: mesh.indices.len(),
8140        }
8141    }
8142
8143    fn mesh_owner_matches(owner: &MeshOwner, other: &MeshOwner) -> bool {
8144        match (owner, other) {
8145            (MeshOwner::Object(a), MeshOwner::Object(b)) => Arc::ptr_eq(a, b),
8146            (MeshOwner::Overlay(a), MeshOwner::Overlay(b)) => Arc::ptr_eq(a, b),
8147            _ => false,
8148        }
8149    }
8150
8151    fn evict_mesh_cache(&mut self) -> bool {
8152        let victim = self
8153            .mesh_cache
8154            .iter()
8155            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
8156            .min_by_key(|(_, entry)| entry.last_touch_frame)
8157            .map(|(key, _)| *key);
8158        let Some(victim) = victim else {
8159            return false;
8160        };
8161        let Some(entry) = self.mesh_cache.remove(&victim) else {
8162            return false;
8163        };
8164        self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8165        true
8166    }
8167
8168    fn reserve_mesh_cache(&mut self, bytes: u64) -> bool {
8169        while self.mesh_cache.len() >= MAX_MESH_CACHE_ENTRIES
8170            || self.mesh_cache_bytes_total.saturating_add(bytes) > MAX_MESH_CACHE_BYTES
8171        {
8172            if !self.evict_mesh_cache() {
8173                return false;
8174            }
8175        }
8176        true
8177    }
8178
8179    fn reserve_mesh_gpu_budget(&mut self, bytes: u64) -> bool {
8180        while self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes {
8181            if !self.evict_mesh_cache() {
8182                return false;
8183            }
8184        }
8185        true
8186    }
8187
8188    fn upload_mesh_geometry_with_owner(
8189        &mut self,
8190        mesh: &repose_core::VectorMeshData,
8191        key: MeshCacheKey,
8192        owner: MeshOwner,
8193    ) -> Option<MeshGpu> {
8194        let touch = self.next_cache_touch();
8195        let cached = self
8196            .mesh_cache
8197            .get(&key)
8198            .filter(|entry| Self::mesh_owner_matches(&entry.owner, &owner))
8199            .map(|entry| entry.gpu.clone());
8200        if let Some(gpu) = cached {
8201            if let Some(entry) = self.mesh_cache.get_mut(&key) {
8202                entry.last_used_frame = self.frame_index;
8203                entry.last_touch_frame = touch;
8204            }
8205            return Some(gpu);
8206        }
8207        if let Some(entry) = self.mesh_cache.remove(&key) {
8208            self.mesh_cache_bytes_total =
8209                self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8210        }
8211        if mesh.vertices.is_empty() || mesh.indices.is_empty() {
8212            log::warn!("vector mesh has no geometry");
8213            return None;
8214        }
8215        if !mesh.indices.len().is_multiple_of(3) {
8216            log::warn!("vector mesh index count is not a triangle list");
8217            return None;
8218        }
8219        let vertex_count = u32::try_from(mesh.vertices.len()).ok()?;
8220        let index_count = u32::try_from(mesh.indices.len()).ok()?;
8221        if mesh.indices.iter().any(|&index| index >= vertex_count) {
8222            log::warn!("vector mesh index is outside its vertex buffer");
8223            return None;
8224        }
8225        if mesh.vertices.iter().any(|vertex| {
8226            vertex.pos.iter().any(|value| !value.is_finite())
8227                || vertex.color.iter().any(|value| !value.is_finite())
8228                || vertex.uv.iter().any(|value| !value.is_finite())
8229        }) {
8230            log::warn!("vector mesh contains non-finite vertex data");
8231            return None;
8232        }
8233        let verts: Vec<MeshVertex> = mesh
8234            .vertices
8235            .iter()
8236            .map(|v| MeshVertex {
8237                pos: v.pos,
8238                color: v.color,
8239                uv: v.uv,
8240            })
8241            .collect();
8242        let vbytes = bytemuck::cast_slice(&verts);
8243        let ibytes = bytemuck::cast_slice(&mesh.indices[..]);
8244        let bytes = u64::try_from(vbytes.len())
8245            .ok()?
8246            .checked_add(u64::try_from(ibytes.len()).ok()?)?;
8247        let max_buffer_size = self.device.limits().max_buffer_size;
8248        if vbytes.len() as u64 > max_buffer_size || ibytes.len() as u64 > max_buffer_size {
8249            return None;
8250        }
8251        let cacheable = bytes <= MAX_MESH_CACHE_BYTES
8252            && self.reserve_mesh_cache(bytes)
8253            && self.reserve_mesh_gpu_budget(bytes);
8254        let vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
8255            label: Some("vector mesh vertices"),
8256            size: vbytes.len().max(4) as u64,
8257            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
8258            mapped_at_creation: false,
8259        });
8260        let indices = self.device.create_buffer(&wgpu::BufferDescriptor {
8261            label: Some("vector mesh indices"),
8262            size: ibytes.len().max(4) as u64,
8263            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
8264            mapped_at_creation: false,
8265        });
8266        self.queue.write_buffer(&vertices, 0, vbytes);
8267        self.queue.write_buffer(&indices, 0, ibytes);
8268        let gpu = MeshGpu {
8269            vertices,
8270            indices,
8271            vertex_count,
8272            index_count,
8273            bytes,
8274        };
8275        if cacheable {
8276            let touch = self.next_cache_touch();
8277            self.mesh_cache.insert(
8278                key,
8279                MeshCacheEntry {
8280                    owner,
8281                    gpu: gpu.clone(),
8282                    last_used_frame: self.frame_index,
8283                    last_touch_frame: touch,
8284                },
8285            );
8286            self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_add(bytes);
8287        }
8288        Some(gpu)
8289    }
8290
8291    fn upload_mesh_geometry(&mut self, mesh: &Arc<repose_core::VectorMeshData>) -> Option<MeshGpu> {
8292        self.upload_mesh_geometry_with_owner(
8293            mesh,
8294            Self::mesh_cache_key(mesh),
8295            MeshOwner::Object(mesh.clone()),
8296        )
8297    }
8298
8299    fn upload_overlay_mesh(
8300        &mut self,
8301        meshes: &Arc<[repose_core::VectorMeshData]>,
8302        index: usize,
8303    ) -> Option<MeshGpu> {
8304        self.upload_mesh_geometry_with_owner(
8305            &meshes[index],
8306            Self::overlay_mesh_cache_key(meshes, index),
8307            MeshOwner::Overlay(meshes.clone()),
8308        )
8309    }
8310
8311    fn alloc_mesh_uniform(&mut self, u: MeshUniform) -> Option<u64> {
8312        let size = std::mem::size_of::<MeshUniform>() as u64;
8313        let next = self
8314            .mesh_uniform_head
8315            .checked_add(self.mesh_uniform_alignment)?;
8316        if next > self.mesh_uniform_cap || next > u64::from(u32::MAX) {
8317            log::warn!("mesh uniform buffer exhausted; remaining meshes skipped");
8318            return None;
8319        }
8320        let slot = self.mesh_uniform_head;
8321        if slot.checked_add(size)? > self.mesh_uniform_cap {
8322            return None;
8323        }
8324        self.queue
8325            .write_buffer(&self.mesh_uniform_buf, slot, bytemuck::bytes_of(&u));
8326        self.mesh_uniform_head = next;
8327        Some(slot)
8328    }
8329
8330    /// Shared isolation + backdrop composite for a source that needs the
8331    /// backdrop: snapshot the parent's region, render the source alone into a
8332    /// translator-owned graphics layer via `emit_source`, then composite that
8333    /// layer over the snapshot with the backdrop-blend shader. Works for
8334    /// surface parents and layer parents alike: the snapshot copy, isolation
8335    /// layer, and composite quad are all expressed in the parent target's
8336    /// pixel space. Callers must invoke this while the current pass targets
8337    /// the recorded parent: the translator never splits passes between here
8338    /// and the appended composite (only `BeginLayer`/perspective push new
8339    /// passes, and neither can intervene mid-call). The executor re-checks
8340    /// this (`BlendLayer.parent`) and skips a misplaced composite rather than
8341    /// drawing over the wrong target.
8342    ///
8343    /// `bounds` is the source's AABB in parent-target pixels; `emit_source`
8344    /// receives the isolation pass, the source-shifted transform, and the
8345    /// layer size in pixels.
8346    #[allow(clippy::too_many_arguments)]
8347    fn emit_isolated_blend_source(
8348        &mut self,
8349        bounds: repose_core::Rect,
8350        blend: repose_core::BlendMode,
8351        current_transform: &repose_core::Transform,
8352        current_pass: &mut Pass,
8353        active_clips: &[ActiveClip],
8354        passes: &mut Vec<Pass>,
8355        id_head: &mut u32,
8356        pass_id_head: &mut u64,
8357        ids_used: &mut Vec<u32>,
8358        scissor: (u32, u32, u32, u32),
8359        encoder: &mut wgpu::CommandEncoder,
8360        fb_w: f32,
8361        fb_h: f32,
8362        emit_source: impl FnOnce(
8363            &mut Self,
8364            &mut Pass,
8365            &repose_core::Transform,
8366            f32,
8367            f32,
8368            &mut wgpu::CommandEncoder,
8369        ),
8370    ) {
8371        if scissor.2 == 0 || scissor.3 == 0 || active_clips.iter().any(ActiveClip::blocked) {
8372            return;
8373        }
8374        let parent_target = current_pass.target;
8375        let (parent_w, parent_h) = match parent_target {
8376            PassTarget::Surface => (fb_w, fb_h),
8377            PassTarget::Layer(id) => match self.layer_pool.get(&id) {
8378                Some(layer) => (layer.width as f32, layer.height as f32),
8379                None => return,
8380            },
8381        };
8382        let local_rect = intersect(
8383            bounds,
8384            repose_core::Rect {
8385                x: 0.0,
8386                y: 0.0,
8387                w: parent_w,
8388                h: parent_h,
8389            },
8390        );
8391        if !local_rect.x.is_finite()
8392            || !local_rect.y.is_finite()
8393            || !local_rect.w.is_finite()
8394            || !local_rect.h.is_finite()
8395            || local_rect.w <= 0.0
8396            || local_rect.h <= 0.0
8397            || local_rect.w > self.device.limits().max_texture_dimension_2d as f32
8398            || local_rect.h > self.device.limits().max_texture_dimension_2d as f32
8399        {
8400            return;
8401        }
8402        let layer_rect = repose_core::Rect {
8403            x: local_rect.x.floor(),
8404            y: local_rect.y.floor(),
8405            w: (local_rect.x + local_rect.w).ceil() - local_rect.x.floor(),
8406            h: (local_rect.y + local_rect.h).ceil() - local_rect.y.floor(),
8407        };
8408        let w = layer_rect.w;
8409        let h = layer_rect.h;
8410        let local_rect = layer_rect;
8411        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8412            return;
8413        }
8414        let layer_id = *id_head;
8415        *id_head = id_head.wrapping_add(1);
8416        ids_used.push(layer_id);
8417
8418        // Snapshot the parent target region before compositing: the blend
8419        // shader samples it as the backdrop. The copy runs at execution
8420        // time, after all earlier passes have rendered.
8421        let snapshot_format = match parent_target {
8422            PassTarget::Surface if self.working_space => wgpu::TextureFormat::Rgba16Float,
8423            PassTarget::Surface => self.output_format,
8424            PassTarget::Layer(_) => self.layer_target_format(),
8425        };
8426        if !self.alloc_blend_snapshot(layer_id, w as u32, h as u32, snapshot_format) {
8427            log::warn!("blend snapshot budget exhausted; blend skipped");
8428            return;
8429        }
8430        let source_pass_id = *pass_id_head;
8431        *pass_id_head += 1;
8432        let resumed_pass_id = *pass_id_head;
8433        *pass_id_head += 1;
8434        self.blend_copies.push(BlendCopy {
8435            pass_id: resumed_pass_id,
8436            blend_id: layer_id,
8437            target: parent_target,
8438            region: layer_rect,
8439        });
8440
8441        // Render the mesh alone into the layer (layer-local shift so the
8442        // layer owns exactly the mesh bbox), then resume the parent pass.
8443        // The composite runs in the resumed parent pass.
8444        let mut layer_pass = Pass {
8445            id: source_pass_id,
8446            target: PassTarget::Layer(layer_id),
8447            initial_scissor: (0, 0, w as u32, h as u32),
8448            active_scissor: Some((0, 0, w as u32, h as u32)),
8449            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8450            cmds: Vec::new(),
8451        };
8452        self.replay_active_clips(
8453            active_clips,
8454            current_transform,
8455            (local_rect.x, local_rect.y),
8456            (w, h),
8457            &mut layer_pass,
8458            encoder,
8459        );
8460        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8461        let shift = repose_core::Transform::translate(-local_rect.x, -local_rect.y);
8462        let local = current_transform.combine(&shift);
8463        emit_source(self, &mut layer_pass, &local, w, h, encoder);
8464        let saved = std::mem::replace(
8465            current_pass,
8466            Pass {
8467                id: resumed_pass_id,
8468                target: parent_target,
8469                initial_scissor: scissor,
8470                active_scissor: Some(scissor),
8471                clear_color: None,
8472                cmds: Vec::new(),
8473            },
8474        );
8475        passes.push(layer_pass);
8476        passes.push(saved);
8477
8478        // Composite quad over the mesh bbox in the parent target's pixel
8479        // space. The executor maps this NDC against `parent`, so layer
8480        // parents at any origin work.
8481        let ndc = {
8482            let cx = local_rect.x + local_rect.w * 0.5;
8483            let cy = local_rect.y + local_rect.h * 0.5;
8484            let ndc_cx = (cx / parent_w) * 2.0 - 1.0;
8485            let ndc_cy = 1.0 - (cy / parent_h) * 2.0;
8486            let ndc_w = (local_rect.w / parent_w) * 2.0;
8487            let ndc_h = (local_rect.h / parent_h) * 2.0;
8488            [ndc_cx, ndc_cy, ndc_w, ndc_h]
8489        };
8490        let inst = BlendInstance {
8491            xywh: ndc,
8492            uv: [0.0, 0.0, 1.0, 1.0],
8493            color: [1.0, 1.0, 1.0, 1.0],
8494            fwd_mat: [1.0, 0.0, 0.0, 1.0],
8495            mode: blend.shader_mode(),
8496            _pad: [0.0; 3],
8497        };
8498        if self
8499            .blend_ring
8500            .grow_to_fit(
8501                &self.device,
8502                encoder,
8503                std::mem::size_of::<BlendInstance>() as u64,
8504            )
8505            .is_err()
8506        {
8507            return;
8508        }
8509        let bytes = bytemuck::bytes_of(&inst);
8510        let Ok(off) = self.blend_ring.alloc_write(&self.queue, bytes) else {
8511            return;
8512        };
8513        current_pass.cmds.push(Cmd::BlendLayer {
8514            off,
8515            cnt: 1,
8516            src_layer: layer_id,
8517            dst_layer: Some(layer_id),
8518            parent: parent_target,
8519            scissor,
8520        });
8521    }
8522
8523    /// [`Self::emit_isolated_blend_source`] for a `VectorMesh`: the isolation
8524    /// layer owns exactly the mesh bbox, shifted so the mesh lands at its
8525    /// world position inside the layer.
8526    #[allow(clippy::too_many_arguments)]
8527    fn emit_isolated_blend(
8528        &mut self,
8529        mesh: std::sync::Arc<repose_core::VectorMeshData>,
8530        transform: [f32; 6],
8531        paint: repose_core::PaintDesc,
8532        blend: repose_core::BlendMode,
8533        current_transform: &repose_core::Transform,
8534        current_pass: &mut Pass,
8535        active_clips: &[ActiveClip],
8536        passes: &mut Vec<Pass>,
8537        id_head: &mut u32,
8538        pass_id_head: &mut u64,
8539        ids_used: &mut Vec<u32>,
8540        scissor: (u32, u32, u32, u32),
8541        encoder: &mut wgpu::CommandEncoder,
8542        fb_w: f32,
8543        fb_h: f32,
8544    ) {
8545        let affine = combine_mesh_affine(current_transform, transform);
8546        let bounds = mesh_aabb(&mesh, affine);
8547        self.emit_isolated_blend_source(
8548            bounds,
8549            blend,
8550            current_transform,
8551            current_pass,
8552            active_clips,
8553            passes,
8554            id_head,
8555            pass_id_head,
8556            ids_used,
8557            scissor,
8558            encoder,
8559            fb_w,
8560            fb_h,
8561            |slf, pass, local, _w, _h, _encoder| {
8562                slf.emit_vector_mesh(
8563                    local,
8564                    &mesh,
8565                    transform,
8566                    &paint,
8567                    repose_core::BlendMode::Alpha,
8568                    &mut pass.cmds,
8569                );
8570            },
8571        );
8572    }
8573
8574    /// [`Self::emit_isolated_blend_source`] for an `ImageBlended` quad. The
8575    /// isolation layer is sized to the fitted draw rect and the image is
8576    /// sampled with its own UVs, so `fit`/`filter`/`source_rect` survive the
8577    /// round trip; the source layer composites with `Alpha` and the blend
8578    /// itself happens in the backdrop pass.
8579    #[allow(clippy::too_many_arguments)]
8580    fn emit_isolated_image(
8581        &mut self,
8582        draw_rect: repose_core::Rect,
8583        handle: u64,
8584        uv_rect: [f32; 4],
8585        tint: repose_core::Color,
8586        tile: bool,
8587        filter: ImageFilter,
8588        blend: repose_core::BlendMode,
8589        current_transform: &repose_core::Transform,
8590        current_pass: &mut Pass,
8591        active_clips: &[ActiveClip],
8592        passes: &mut Vec<Pass>,
8593        id_head: &mut u32,
8594        pass_id_head: &mut u64,
8595        ids_used: &mut Vec<u32>,
8596        scissor: (u32, u32, u32, u32),
8597        encoder: &mut wgpu::CommandEncoder,
8598        fb_w: f32,
8599        fb_h: f32,
8600    ) {
8601        let bounds = affine_aabb(current_transform, &draw_rect);
8602        self.emit_isolated_blend_source(
8603            bounds,
8604            blend,
8605            current_transform,
8606            current_pass,
8607            active_clips,
8608            passes,
8609            id_head,
8610            pass_id_head,
8611            ids_used,
8612            scissor,
8613            encoder,
8614            fb_w,
8615            fb_h,
8616            |slf, pass, local, w, h, encoder| {
8617                let (ndc_center, fwd_mat) = rect_to_instance_ndc(draw_rect, local, w, h);
8618                let inst = GlyphInstance {
8619                    xywh: ndc_center,
8620                    uv: uv_rect,
8621                    color: tint.to_linear(),
8622                    fwd_mat,
8623                };
8624                if let Some((off, _)) =
8625                    slf.glyph_color
8626                        .upload(&slf.device, &slf.queue, encoder, &[inst])
8627                {
8628                    pass.cmds.push(Cmd::ImageRgba {
8629                        off,
8630                        cnt: 1,
8631                        handle,
8632                        filter,
8633                        tile,
8634                        blend: repose_core::BlendMode::Alpha,
8635                    });
8636                }
8637            },
8638        );
8639    }
8640
8641    fn recycle_blend_snapshot(&mut self, snapshot: BlendSnapshot) {
8642        let key = snapshot.key;
8643        if let Some(old) = self.blend_snapshot_pool.insert(key, snapshot) {
8644            self.blend_snapshot_bytes_total =
8645                self.blend_snapshot_bytes_total.saturating_sub(old.bytes);
8646        }
8647        while self.blend_snapshot_bytes_total > MAX_BLEND_SNAPSHOT_BYTES
8648            || self.blend_snapshot_pool.len() > MAX_BLEND_SNAPSHOT_POOL_ENTRIES
8649        {
8650            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8651                break;
8652            };
8653            if let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) {
8654                self.blend_snapshot_bytes_total = self
8655                    .blend_snapshot_bytes_total
8656                    .saturating_sub(snapshot.bytes);
8657            }
8658        }
8659    }
8660
8661    fn recycle_blend_snapshots(&mut self) {
8662        for (_, snapshot) in std::mem::take(&mut self.blend_snapshots) {
8663            self.recycle_blend_snapshot(snapshot);
8664        }
8665    }
8666
8667    fn alloc_blend_snapshot(
8668        &mut self,
8669        layer_id: u32,
8670        w: u32,
8671        h: u32,
8672        format: wgpu::TextureFormat,
8673    ) -> bool {
8674        let key = BlendSnapshotKey {
8675            format,
8676            width: w,
8677            height: h,
8678        };
8679        if self
8680            .blend_snapshots
8681            .get(&layer_id)
8682            .is_some_and(|snapshot| snapshot.key == key)
8683        {
8684            return true;
8685        }
8686        if w == 0 || h == 0 {
8687            return false;
8688        }
8689        if let Some(old) = self.blend_snapshots.remove(&layer_id) {
8690            self.recycle_blend_snapshot(old);
8691        }
8692        if let Some(snapshot) = self.blend_snapshot_pool.remove(&key) {
8693            self.blend_snapshots.insert(layer_id, snapshot);
8694            return true;
8695        }
8696        let Ok(bytes) = texture_storage_bytes(format, w, h) else {
8697            return false;
8698        };
8699        if bytes > MAX_BLEND_SNAPSHOT_BYTES {
8700            return false;
8701        }
8702        while self.blend_snapshot_bytes_total.saturating_add(bytes) > MAX_BLEND_SNAPSHOT_BYTES
8703            || self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes
8704        {
8705            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8706                return false;
8707            };
8708            let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) else {
8709                return false;
8710            };
8711            self.blend_snapshot_bytes_total = self
8712                .blend_snapshot_bytes_total
8713                .saturating_sub(snapshot.bytes);
8714        }
8715        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
8716            label: Some("blend backdrop snapshot"),
8717            size: wgpu::Extent3d {
8718                width: w,
8719                height: h,
8720                depth_or_array_layers: 1,
8721            },
8722            mip_level_count: 1,
8723            sample_count: 1,
8724            dimension: wgpu::TextureDimension::D2,
8725            format,
8726            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
8727            view_formats: &[],
8728        });
8729        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
8730        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8731            label: Some("blend snapshot bind"),
8732            layout: &self.image_bind_layout_rgba,
8733            entries: &[
8734                wgpu::BindGroupEntry {
8735                    binding: 0,
8736                    resource: wgpu::BindingResource::TextureView(&view),
8737                },
8738                wgpu::BindGroupEntry {
8739                    binding: 1,
8740                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
8741                },
8742            ],
8743        });
8744        self.blend_snapshot_bytes_total = self.blend_snapshot_bytes_total.saturating_add(bytes);
8745        self.blend_snapshots.insert(
8746            layer_id,
8747            BlendSnapshot {
8748                key,
8749                texture: tex,
8750                bind,
8751                bytes,
8752            },
8753        );
8754        true
8755    }
8756
8757    fn remove_blend_snapshot(&mut self, layer_id: u32) {
8758        if let Some(snapshot) = self.blend_snapshots.remove(&layer_id) {
8759            self.recycle_blend_snapshot(snapshot);
8760        }
8761    }
8762
8763    fn blend_snapshot_texture(&self, layer_id: u32) -> Option<wgpu::Texture> {
8764        self.blend_snapshots
8765            .get(&layer_id)
8766            .map(|s| s.texture.clone())
8767    }
8768
8769    #[allow(clippy::too_many_arguments)]
8770    fn emit_vector_mesh(
8771        &mut self,
8772        current_transform: &Transform,
8773        mesh: &Arc<repose_core::VectorMeshData>,
8774        transform: [f32; 6],
8775        paint: &repose_core::PaintDesc,
8776        blend: repose_core::BlendMode,
8777        cmds: &mut Vec<Cmd>,
8778    ) {
8779        let affine = combine_mesh_affine(current_transform, transform);
8780        let Some(mesh_gpu) = self.upload_mesh_geometry(mesh) else {
8781            return;
8782        };
8783        let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(affine, paint)) else {
8784            return;
8785        };
8786        cmds.push(Cmd::VectorMesh {
8787            mesh: mesh_gpu,
8788            uoff,
8789            blend,
8790        });
8791    }
8792
8793    fn touch_callback_scope(&mut self, scope: CallbackScopeKey) {
8794        self.callback_scope_clock = self.callback_scope_clock.wrapping_add(1);
8795        let tick = self.callback_scope_clock;
8796        self.callback_scope_uses.insert(
8797            scope,
8798            CallbackScopeUse {
8799                tick,
8800                frame: self.frame_index,
8801            },
8802        );
8803    }
8804
8805    fn remove_callback_scope(&mut self, scope: &CallbackScopeKey) {
8806        self.callback_scoped_resources.remove(scope);
8807        self.callback_scope_uses.remove(scope);
8808        self.callback_scope_payloads.remove(scope);
8809    }
8810
8811    fn evict_callback_scope(&mut self, protected: &HashSet<CallbackScopeKey>) -> bool {
8812        let candidate = self
8813            .callback_scoped_resources
8814            .keys()
8815            .filter(|scope| !protected.contains(*scope))
8816            .min_by_key(|scope| {
8817                self.callback_scope_uses
8818                    .get(scope)
8819                    .map(|usage| usage.tick)
8820                    .unwrap_or(0)
8821            })
8822            .cloned();
8823        let Some(scope) = candidate else {
8824            return false;
8825        };
8826        self.remove_callback_scope(&scope);
8827        true
8828    }
8829
8830    fn prune_callback_scopes(&mut self) {
8831        let current_frame = self.frame_index;
8832        let mut dead: Vec<(CallbackScopeKey, u64)> = self
8833            .callback_scope_uses
8834            .iter()
8835            .filter_map(|(scope, usage)| {
8836                let payload_dead = self
8837                    .callback_scope_payloads
8838                    .get(scope)
8839                    .is_none_or(|weak| weak.upgrade().is_none());
8840                (payload_dead && !scope.callback.reusable).then(|| (scope.clone(), usage.tick))
8841            })
8842            .collect();
8843        dead.sort_by_key(|(_, tick)| *tick);
8844        for (scope, _) in dead {
8845            self.remove_callback_scope(&scope);
8846        }
8847
8848        if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8849            return;
8850        }
8851        let mut inactive: Vec<(CallbackScopeKey, u64)> = self
8852            .callback_scope_uses
8853            .iter()
8854            .filter(|(_, usage)| usage.frame != current_frame)
8855            .map(|(scope, usage)| (scope.clone(), usage.tick))
8856            .collect();
8857        inactive.sort_by_key(|(_, tick)| *tick);
8858        for (scope, _) in inactive {
8859            if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8860                break;
8861            }
8862            self.remove_callback_scope(&scope);
8863        }
8864    }
8865
8866    pub fn begin_frame(&mut self) -> anyhow::Result<()> {
8867        if self.frame_active {
8868            self.last_render_error = Some("renderer frame is already active".into());
8869            anyhow::bail!("renderer frame is already active");
8870        }
8871        self.last_render_error = None;
8872        self.frame_active = true;
8873        self.frame_index = self.frame_index.wrapping_add(1);
8874        self.apply_atlas_pending();
8875        self.slug_cache.next_frame();
8876        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8877            composite.begin_frame();
8878        }
8879        for resources in self.callback_scoped_resources.values_mut() {
8880            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8881                composite.begin_frame();
8882            }
8883        }
8884        Ok(())
8885    }
8886
8887    pub fn end_frame(&mut self) {
8888        if !self.frame_active {
8889            return;
8890        }
8891        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8892            composite.end_frame();
8893        }
8894        for resources in self.callback_scoped_resources.values_mut() {
8895            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8896                composite.end_frame();
8897            }
8898        }
8899        self.prune_callback_scopes();
8900        self.evict_unused_images();
8901        self.frame_active = false;
8902    }
8903
8904    pub fn last_render_error(&self) -> Option<&str> {
8905        self.last_render_error.as_deref()
8906    }
8907
8908    pub fn render_scene_to_encoder(
8909        &mut self,
8910        scene: &Scene,
8911        encoder: &mut wgpu::CommandEncoder,
8912        target_view: &wgpu::TextureView,
8913        clear_color_override: Option<[f64; 4]>,
8914    ) {
8915        self.render_scene_to_encoder_with_texture(
8916            scene,
8917            encoder,
8918            target_view,
8919            None,
8920            clear_color_override,
8921        )
8922    }
8923
8924    /// Same as [`render_scene_to_encoder`](Self::render_scene_to_encoder),
8925    /// plus the render target texture for isolated-blend backdrop snapshots.
8926    /// Pass `None` when the texture is unavailable (swapchain views); then
8927    /// surface-targeted isolated blends are skipped (the isolated source
8928    /// layer composites nowhere, so the mesh disappears).
8929    pub fn render_scene_to_encoder_with_texture(
8930        &mut self,
8931        scene: &Scene,
8932        encoder: &mut wgpu::CommandEncoder,
8933        target_view: &wgpu::TextureView,
8934        target_texture: Option<&wgpu::Texture>,
8935        clear_color_override: Option<[f64; 4]>,
8936    ) {
8937        if self.output_width == 0 || self.output_height == 0 {
8938            return;
8939        }
8940        self.sync_text_caches();
8941        fn visible(aabb: repose_core::Rect, clip: repose_core::Rect) -> bool {
8942            if !aabb.x.is_finite()
8943                || !aabb.y.is_finite()
8944                || !aabb.w.is_finite()
8945                || !aabb.h.is_finite()
8946                || aabb.w <= 0.0
8947                || aabb.h <= 0.0
8948            {
8949                return false;
8950            }
8951            let aabb = repose_core::Rect {
8952                x: aabb.x - 1.0,
8953                y: aabb.y - 1.0,
8954                w: aabb.w + 2.0,
8955                h: aabb.h + 2.0,
8956            };
8957            let clipped = intersect(aabb, clip);
8958            clipped.x.is_finite()
8959                && clipped.y.is_finite()
8960                && clipped.w.is_finite()
8961                && clipped.h.is_finite()
8962                && clipped.w > 0.0
8963                && clipped.h > 0.0
8964        }
8965
8966        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
8967            let x0 = (x / fb_w) * 2.0 - 1.0;
8968            let y0 = 1.0 - (y / fb_h) * 2.0;
8969            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
8970            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
8971            let min_x = x0.min(x1);
8972            let min_y = y0.min(y1);
8973            let w_ndc = (x1 - x0).abs();
8974            let h_ndc = (y1 - y0).abs();
8975            [min_x, min_y, w_ndc, h_ndc]
8976        }
8977
8978        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
8979            if fb_w == 0
8980                || fb_h == 0
8981                || !r.x.is_finite()
8982                || !r.y.is_finite()
8983                || !r.w.is_finite()
8984                || !r.h.is_finite()
8985                || r.w <= 0.0
8986                || r.h <= 0.0
8987            {
8988                return (0, 0, 0, 0);
8989            }
8990            // Inward: a clip must not admit any pixel outside the rect it
8991            // came from. Rounding out let clipped content bleed a pixel past
8992            // every clip edge, which shows up as a 1px line hugging the edge
8993            // of a letterboxed or clipped view.
8994            let x0 = r.x.ceil().max(0.0).min(fb_w as f32);
8995            let y0 = r.y.ceil().max(0.0).min(fb_h as f32);
8996            let x1 = (r.x + r.w).floor().max(x0).min(fb_w as f32);
8997            let y1 = (r.y + r.h).floor().max(y0).min(fb_h as f32);
8998            if x1 <= x0 || y1 <= y0 {
8999                return (0, 0, 0, 0);
9000            }
9001            (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
9002        }
9003
9004        let fb_w = self.output_width as f32;
9005        let fb_h = self.output_height as f32;
9006
9007        let mut passes: Vec<Pass> = Vec::with_capacity(1);
9008        let clear_color = clear_color_override
9009            .map(|color| {
9010                let alpha = color[3];
9011                [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha]
9012            })
9013            .unwrap_or_else(|| {
9014                let color = scene.clear_color.to_linear();
9015                let alpha = color[3] as f64;
9016                [
9017                    color[0] as f64 * alpha,
9018                    color[1] as f64 * alpha,
9019                    color[2] as f64 * alpha,
9020                    alpha,
9021                ]
9022            });
9023        let mut next_pass_id = 0u64;
9024        let mut current_pass: Pass = Pass {
9025            id: next_pass_id,
9026            target: PassTarget::Surface,
9027            initial_scissor: (0, 0, self.output_width, self.output_height),
9028            active_scissor: Some((0, 0, self.output_width, self.output_height)),
9029            clear_color: Some([
9030                clear_color[0] as f32,
9031                clear_color[1] as f32,
9032                clear_color[2] as f32,
9033                clear_color[3] as f32,
9034            ]),
9035            cmds: Vec::with_capacity(scene.nodes.len()),
9036        };
9037        next_pass_id += 1;
9038        let mut target_stack: Vec<PassTarget> = Vec::new();
9039        let mut layer_stack: Vec<LayerState> = Vec::new();
9040        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
9041
9042        struct Batch {
9043            rects: Vec<RectInstance>,
9044            borders: Vec<BorderInstance>,
9045            ellipses: Vec<EllipseInstance>,
9046            e_borders: Vec<EllipseBorderInstance>,
9047            arcs: Vec<ArcInstance>,
9048            masks: Vec<GlyphInstance>,
9049            colors: Vec<GlyphInstance>,
9050        }
9051
9052        impl Batch {
9053            fn new() -> Self {
9054                Self {
9055                    rects: vec![],
9056                    borders: vec![],
9057                    ellipses: vec![],
9058                    e_borders: vec![],
9059                    arcs: vec![],
9060                    masks: vec![],
9061                    colors: vec![],
9062                }
9063            }
9064
9065            fn is_empty(&self) -> bool {
9066                self.rects.is_empty()
9067                    && self.borders.is_empty()
9068                    && self.ellipses.is_empty()
9069                    && self.e_borders.is_empty()
9070                    && self.arcs.is_empty()
9071                    && self.masks.is_empty()
9072                    && self.colors.is_empty()
9073            }
9074
9075            fn flush(
9076                &mut self,
9077                pipes: (
9078                    &mut InstancedPipe<RectInstance>,
9079                    &mut InstancedPipe<BorderInstance>,
9080                    &mut InstancedPipe<EllipseInstance>,
9081                    &mut InstancedPipe<EllipseBorderInstance>,
9082                    &mut InstancedPipe<ArcInstance>,
9083                ),
9084                glyph_pipes: (
9085                    &mut InstancedPipe<GlyphInstance>,
9086                    &mut InstancedPipe<GlyphInstance>,
9087                ),
9088                device: &wgpu::Device,
9089                queue: &wgpu::Queue,
9090                encoder: &mut wgpu::CommandEncoder,
9091                cmds: &mut Vec<Cmd>,
9092            ) {
9093                let (rects, borders, ellipses, e_borders, arcs) = pipes;
9094                let (masks, colors) = glyph_pipes;
9095
9096                macro_rules! flush_one {
9097                    ($buf:ident, $pipe:expr, $variant:ident) => {
9098                        if !self.$buf.is_empty() {
9099                            if let Some((off, cnt)) =
9100                                $pipe.upload(device, queue, encoder, &self.$buf)
9101                            {
9102                                cmds.push(Cmd::$variant { off, cnt });
9103                            }
9104                            self.$buf.clear();
9105                        }
9106                    };
9107                }
9108
9109                flush_one!(rects, rects, Rect);
9110                flush_one!(borders, borders, Border);
9111                flush_one!(ellipses, ellipses, Ellipse);
9112                flush_one!(e_borders, e_borders, EllipseBorder);
9113                flush_one!(arcs, arcs, Arc);
9114                flush_one!(masks, masks, GlyphsMask);
9115                flush_one!(colors, colors, GlyphsColor);
9116            }
9117        }
9118
9119        self.rects.reset();
9120        self.borders.reset();
9121        self.ellipses.reset();
9122        self.ellipse_borders.reset();
9123        self.arcs.reset();
9124        self.glyph_mask.reset();
9125        self.glyph_color.reset();
9126        self.clip_ring.reset();
9127        self.blur_ring.reset();
9128        self.nv12.reset();
9129
9130        self.slug_ring.reset();
9131        self.mesh_uniform_head = 0;
9132        self.projective_ring.reset();
9133        self.blend_ring.reset();
9134        for id in self.flatten_layer_ids.drain(..) {
9135            if let Some(layer) = self.layer_pool.remove(&id) {
9136                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
9137                if layer.transient {
9138                    self.transient_layer_bytes_total =
9139                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
9140                }
9141            }
9142        }
9143        self.producer_layer_ids.clear();
9144        self.recycle_blend_snapshots();
9145        self.blend_copies.clear();
9146        let mut batch = Batch::new();
9147        // The blend mode is part of the run key: instances only batch together when
9148        // they resolve to the same pipeline.
9149        let mut image_run: Option<(
9150            u64,
9151            ImageFilter,
9152            bool,
9153            repose_core::BlendMode,
9154            Vec<GlyphInstance>,
9155        )> = None;
9156        let mut nv12_run: Option<(u64, ImageFilter, repose_core::BlendMode, Vec<Nv12Instance>)> =
9157            None;
9158        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
9159        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
9160        let mut flatten_stack: Vec<FlattenRecord> = Vec::new();
9161        let mut flatten_id_head: u32 = FLATTEN_ID_BASE;
9162        let mut flatten_ids_used: Vec<u32> = Vec::new();
9163        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
9164        let mut active_clips: Vec<ActiveClip> = Vec::with_capacity(8);
9165        let mut root_clip_rect = repose_core::Rect {
9166            x: 0.0,
9167            y: 0.0,
9168            w: fb_w,
9169            h: fb_h,
9170        };
9171
9172        let mut current_prim: Option<&'static str> = None;
9173
9174        macro_rules! flush_if_prim_changed {
9175            ($prim:literal, $pipe:expr) => {
9176                if current_prim != Some($prim) {
9177                    flush_batch!();
9178                    current_prim = Some($prim);
9179                }
9180            };
9181        }
9182
9183        macro_rules! flush_image_runs {
9184            () => {{
9185                if let Some((handle, filter, tile, blend, instances)) = image_run.take()
9186                    && let Some((off, cnt)) =
9187                        self.glyph_color
9188                            .upload(&self.device, &self.queue, encoder, &instances)
9189                {
9190                    current_pass.cmds.push(Cmd::ImageRgba {
9191                        off,
9192                        cnt,
9193                        handle,
9194                        filter,
9195                        tile,
9196                        blend,
9197                    });
9198                }
9199                if let Some((handle, filter, blend, instances)) = nv12_run.take()
9200                    && let Some((off, cnt)) =
9201                        self.nv12
9202                            .upload(&self.device, &self.queue, encoder, &instances)
9203                {
9204                    current_pass.cmds.push(Cmd::ImageNv12 {
9205                        off,
9206                        cnt,
9207                        handle,
9208                        filter,
9209                        blend,
9210                    });
9211                }
9212            }};
9213        }
9214
9215        macro_rules! flush_primitives {
9216            () => {{
9217                if !batch.is_empty() {
9218                    batch.flush(
9219                        (
9220                            &mut self.rects,
9221                            &mut self.borders,
9222                            &mut self.ellipses,
9223                            &mut self.ellipse_borders,
9224                            &mut self.arcs,
9225                        ),
9226                        (&mut self.glyph_mask, &mut self.glyph_color),
9227                        &self.device,
9228                        &self.queue,
9229                        encoder,
9230                        &mut current_pass.cmds,
9231                    )
9232                }
9233            }};
9234        }
9235
9236        macro_rules! flush_batch {
9237            () => {{
9238                flush_image_runs!();
9239                flush_primitives!();
9240            }};
9241        }
9242        let mut pending_layer_composite: Option<Cmd> = None;
9243        let shadow_layer_ids: HashSet<u32> = scene
9244            .nodes
9245            .iter()
9246            .filter_map(|node| match node {
9247                SceneNode::CompositeShadow { layer_id, .. } => Some(*layer_id),
9248                _ => None,
9249            })
9250            .collect();
9251        macro_rules! flush_pending_layer {
9252            () => {
9253                if let Some(command) = pending_layer_composite.take() {
9254                    current_pass.cmds.push(command);
9255                }
9256            };
9257        }
9258        for node in &scene.nodes {
9259            let t_identity = Transform::identity();
9260            let current_transform = transform_stack.last().unwrap_or(&t_identity);
9261
9262            // `Image` and `ImageBlended` differ only in the blend mode, and
9263            // or-patterns cannot bind a field one variant lacks, so lift both
9264            // out of the match and share the whole image path below.
9265            let image_spec = match node {
9266                SceneNode::Image {
9267                    rect,
9268                    handle,
9269                    tint,
9270                    style,
9271                } => Some((*rect, *handle, *tint, *style, repose_core::BlendMode::Alpha)),
9272                SceneNode::ImageBlended {
9273                    rect,
9274                    handle,
9275                    tint,
9276                    style,
9277                    blend,
9278                } => Some((*rect, *handle, *tint, *style, *blend)),
9279                _ => None,
9280            };
9281            if let Some((rect, handle, tint, style, blend)) = image_spec {
9282                let ImagePaintStyle {
9283                    fit,
9284                    filter,
9285                    source_rect,
9286                    alignment,
9287                } = style;
9288                let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9289                if !visible(affine_aabb(current_transform, &rect), clip) {
9290                    continue;
9291                }
9292                flush_batch!();
9293                let (img_w, img_h, is_nv12) = match self.resolve_image_for_draw(handle) {
9294                    Some(wh) => wh,
9295                    None => {
9296                        log::warn!("Image handle {} not found", handle);
9297                        continue;
9298                    }
9299                };
9300                let Some(source) = resolve_image_source(source_rect, img_w, img_h) else {
9301                    log::warn!(
9302                        "Image handle {} has invalid source rect {:?}",
9303                        handle,
9304                        source_rect
9305                    );
9306                    continue;
9307                };
9308                let Some((draw_rect, uv_rect)) = image_fit_geometry(rect, fit, alignment, &source)
9309                else {
9310                    continue;
9311                };
9312                let tile = matches!(fit, ImageFit::Tile);
9313                // Mirror the mesh path: backdrop-dependent modes isolate,
9314                // and so do the fixed-function ones when the source is
9315                // translucent (their blend states assume opaque source).
9316                let translucent_fixed_blend = matches!(
9317                    blend,
9318                    repose_core::BlendMode::Add
9319                        | repose_core::BlendMode::Multiply
9320                        | repose_core::BlendMode::Screen
9321                        | repose_core::BlendMode::Darken
9322                        | repose_core::BlendMode::Lighten
9323                ) && tint.3 < 255;
9324                if blend.needs_isolation() || translucent_fixed_blend {
9325                    if is_nv12 {
9326                        // NV12 sampling is only wired into the image
9327                        // pipeline; isolating would need an RGBA layer.
9328                        log::warn!("blend on an NV12 image is not supported");
9329                    } else {
9330                        let blend_scissor = to_scissor(
9331                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
9332                            current_target_size.0 as u32,
9333                            current_target_size.1 as u32,
9334                        );
9335                        self.emit_isolated_image(
9336                            draw_rect,
9337                            handle,
9338                            uv_rect,
9339                            tint,
9340                            tile,
9341                            filter,
9342                            blend,
9343                            current_transform,
9344                            &mut current_pass,
9345                            &active_clips,
9346                            &mut passes,
9347                            &mut flatten_id_head,
9348                            &mut next_pass_id,
9349                            &mut flatten_ids_used,
9350                            blend_scissor,
9351                            encoder,
9352                            fb_w,
9353                            fb_h,
9354                        );
9355                    }
9356                    continue;
9357                }
9358                let (ndc_center, fwd_mat) = rect_to_instance_ndc(
9359                    draw_rect,
9360                    current_transform,
9361                    current_target_size.0,
9362                    current_target_size.1,
9363                );
9364                if is_nv12 {
9365                    if image_run.is_some() {
9366                        flush_image_runs!();
9367                    }
9368                    let (uv_x_offset, uv_y_offset) =
9369                        if let Some(ImageTex::Nv12 {
9370                            w, h, color_info, ..
9371                        }) = self.images.get(&handle)
9372                        {
9373                            let chroma_w = w.div_ceil(2).max(1) as f32;
9374                            let chroma_h = h.div_ceil(2).max(1) as f32;
9375                            match color_info.chroma_siting {
9376                                ChromaSiting::Center => (0.0, 0.0),
9377                                ChromaSiting::Left => (-0.5 / chroma_w, 0.0),
9378                                ChromaSiting::TopLeft => (-0.5 / chroma_w, -0.5 / chroma_h),
9379                            }
9380                        } else {
9381                            (0.0, 0.0)
9382                        };
9383                    let inst = Nv12Instance {
9384                        xywh: ndc_center,
9385                        uv: uv_rect,
9386                        color: tint.to_linear(),
9387                        uv_x_offset,
9388                        uv_y_offset,
9389                        fwd_mat,
9390                    };
9391                    if nv12_run
9392                        .as_ref()
9393                        .is_some_and(|(run_handle, run_filter, run_blend, _)| {
9394                            *run_handle != handle || *run_filter != filter || *run_blend != blend
9395                        })
9396                    {
9397                        flush_image_runs!();
9398                    }
9399                    match &mut nv12_run {
9400                        Some((run_handle, run_filter, run_blend, instances))
9401                            if *run_handle == handle
9402                                && *run_filter == filter
9403                                && *run_blend == blend =>
9404                        {
9405                            instances.push(inst)
9406                        }
9407                        _ => nv12_run = Some((handle, filter, blend, vec![inst])),
9408                    }
9409                } else {
9410                    if nv12_run.is_some() {
9411                        flush_image_runs!();
9412                    }
9413                    let inst = GlyphInstance {
9414                        xywh: ndc_center,
9415                        uv: uv_rect,
9416                        color: tint.to_linear(),
9417                        fwd_mat,
9418                    };
9419                    if image_run.as_ref().is_some_and(
9420                        |(run_handle, run_filter, run_tile, run_blend, _)| {
9421                            *run_handle != handle
9422                                || *run_filter != filter
9423                                || *run_tile != tile
9424                                || *run_blend != blend
9425                        },
9426                    ) {
9427                        flush_image_runs!();
9428                    }
9429                    match &mut image_run {
9430                        Some((run_handle, run_filter, run_tile, run_blend, instances))
9431                            if *run_handle == handle
9432                                && *run_filter == filter
9433                                && *run_tile == tile
9434                                && *run_blend == blend =>
9435                        {
9436                            instances.push(inst)
9437                        }
9438                        _ => image_run = Some((handle, filter, tile, blend, vec![inst])),
9439                    }
9440                }
9441                continue;
9442            }
9443
9444            match node {
9445                SceneNode::Rect {
9446                    rect,
9447                    brush,
9448                    radius,
9449                } => {
9450                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9451                    if !visible(affine_aabb(current_transform, rect), clip) {
9452                        continue;
9453                    }
9454                    flush_if_prim_changed!("rect", &self.rects);
9455                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9456                        *rect,
9457                        current_transform,
9458                        current_target_size.0,
9459                        current_target_size.1,
9460                    );
9461                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9462                        brush_to_shape_fields(brush, rect, current_transform);
9463                    batch.rects.push(RectInstance {
9464                        xywh: ndc,
9465                        radii: radius.map(|r| r.0),
9466                        color0,
9467                        color1,
9468                        fwd_mat,
9469                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9470                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9471                        _pad: [0.0; 3],
9472                    });
9473                }
9474                SceneNode::Border {
9475                    rect,
9476                    brush,
9477                    width,
9478                    radius,
9479                } => {
9480                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9481                    if !visible(affine_aabb(current_transform, rect), clip) {
9482                        continue;
9483                    }
9484                    flush_if_prim_changed!("border", &self.borders);
9485                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9486                        *rect,
9487                        current_transform,
9488                        current_target_size.0,
9489                        current_target_size.1,
9490                    );
9491                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9492                        brush_to_shape_fields(brush, rect, current_transform);
9493                    batch.borders.push(BorderInstance {
9494                        xywh: ndc,
9495                        radii: radius.map(|r| r.0),
9496                        color0,
9497                        color1,
9498                        fwd_mat,
9499                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9500                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9501                        stroke: width.0,
9502                        _pad: [0.0; 2],
9503                    });
9504                }
9505                SceneNode::Ellipse { rect, brush } => {
9506                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9507                    if !visible(affine_aabb(current_transform, rect), clip) {
9508                        continue;
9509                    }
9510                    flush_if_prim_changed!("ellipse", &self.ellipses);
9511                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9512                        *rect,
9513                        current_transform,
9514                        current_target_size.0,
9515                        current_target_size.1,
9516                    );
9517                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9518                        brush_to_shape_fields(brush, rect, current_transform);
9519                    batch.ellipses.push(EllipseInstance {
9520                        xywh: ndc,
9521                        color0,
9522                        color1,
9523                        fwd_mat,
9524                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9525                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9526                        _pad: [0.0; 1],
9527                    });
9528                }
9529                SceneNode::EllipseBorder { rect, brush, width } => {
9530                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9531                    let cull_pad = width.0.abs() * 0.5 + 2.0;
9532                    let cull_rect = repose_core::Rect {
9533                        x: rect.x - cull_pad,
9534                        y: rect.y - cull_pad,
9535                        w: rect.w + cull_pad * 2.0,
9536                        h: rect.h + cull_pad * 2.0,
9537                    };
9538                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9539                        continue;
9540                    }
9541                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
9542                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9543                        *rect,
9544                        current_transform,
9545                        current_target_size.0,
9546                        current_target_size.1,
9547                    );
9548                    let pad_px = width.0 * 0.5 + 2.0;
9549                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9550                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9551                        brush_to_shape_fields(brush, rect, current_transform);
9552                    batch.e_borders.push(EllipseBorderInstance {
9553                        xywh: ndc,
9554                        color0,
9555                        color1,
9556                        fwd_mat,
9557                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9558                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9559                        stroke: width.0,
9560                        pad,
9561                        _pad: 0.0,
9562                    });
9563                }
9564                SceneNode::Arc {
9565                    rect,
9566                    start_angle,
9567                    sweep_angle,
9568                    stroke_width,
9569                    brush,
9570                    cap,
9571                } => {
9572                    if !start_angle.is_finite()
9573                        || !sweep_angle.is_finite()
9574                        || !stroke_width.0.is_finite()
9575                        || !rect.x.is_finite()
9576                        || !rect.y.is_finite()
9577                        || !rect.w.is_finite()
9578                        || !rect.h.is_finite()
9579                        || rect.w <= 0.0
9580                        || rect.h <= 0.0
9581                        || stroke_width.0 <= 0.0
9582                        || sweep_angle.abs() <= 1e-6
9583                    {
9584                        continue;
9585                    }
9586                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9587                    let cull_pad = stroke_width.0.abs() * 0.5 + 2.0;
9588                    let cull_rect = repose_core::Rect {
9589                        x: rect.x - cull_pad,
9590                        y: rect.y - cull_pad,
9591                        w: rect.w + cull_pad * 2.0,
9592                        h: rect.h + cull_pad * 2.0,
9593                    };
9594                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9595                        continue;
9596                    }
9597                    flush_if_prim_changed!("arc", &self.arcs);
9598                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9599                        *rect,
9600                        current_transform,
9601                        current_target_size.0.max(1.0),
9602                        current_target_size.1.max(1.0),
9603                    );
9604                    let pad_px = stroke_width.0 * 0.5 + 2.0;
9605                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9606                    let cap_val = match cap {
9607                        StrokeCap::Butt => 0.0,
9608                        StrokeCap::Round => 1.0,
9609                        StrokeCap::Square => 2.0,
9610                    };
9611                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9612                        brush_to_shape_fields(brush, rect, current_transform);
9613                    let (start, sweep) = if *sweep_angle < 0.0 {
9614                        (*start_angle + *sweep_angle, -*sweep_angle)
9615                    } else {
9616                        (*start_angle, *sweep_angle)
9617                    };
9618                    batch.arcs.push(ArcInstance {
9619                        xywh: ndc,
9620                        color0,
9621                        color1,
9622                        fwd_mat,
9623                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9624                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, cap_val as u32),
9625                        start_angle: start,
9626                        sweep_angle: sweep,
9627                        stroke: stroke_width.0,
9628                        pad,
9629                        _pad: 0.0,
9630                    });
9631                }
9632                SceneNode::Text {
9633                    rect,
9634                    text,
9635                    color,
9636                    size,
9637                    style,
9638                } => {
9639                    let TextPaintStyle {
9640                        font_family,
9641                        font_weight,
9642                        font_style,
9643                        text_decoration,
9644                        letter_spacing,
9645                        font_variation_settings,
9646                        baseline_shift,
9647                        draw_style,
9648                        ..
9649                    } = style;
9650                    let px = size.0;
9651                    if !px.is_finite()
9652                        || px <= 0.0
9653                        || !rect.x.is_finite()
9654                        || !rect.y.is_finite()
9655                        || !rect.w.is_finite()
9656                        || !rect.h.is_finite()
9657                    {
9658                        continue;
9659                    }
9660                    let margin = px * 4.0 + letter_spacing.0.abs() * 2.0;
9661                    let text_bounds = repose_core::Rect {
9662                        x: rect.x - margin,
9663                        y: rect.y - margin,
9664                        w: rect.w + margin * 2.0,
9665                        h: rect.h + margin * 2.0,
9666                    };
9667                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9668                    if !visible(affine_aabb(current_transform, &text_bounds), clip) {
9669                        continue;
9670                    }
9671                    flush_batch!(); // flush any prior primitives
9672
9673                    let lh_ratio = rect.h / px;
9674                    let fw = font_weight.0;
9675                    let fs = if *font_style == FontStyle::Italic {
9676                        1
9677                    } else {
9678                        0
9679                    };
9680                    let shaped = self.shape_text_cached(
9681                        text,
9682                        px,
9683                        lh_ratio,
9684                        *font_family,
9685                        fw,
9686                        fs,
9687                        letter_spacing.0,
9688                        font_variation_settings.as_ref(),
9689                    );
9690                    let baseline_y = shaped.glyphs.first().map(|g| rect.y + g.y);
9691
9692                    let fwd = forward_rs_mat(current_transform);
9693                    let has_linear = fwd != [1.0, 0.0, 0.0, 1.0];
9694
9695                    let lin = current_transform.linear();
9696                    let tr_x = current_transform.translate_x;
9697                    let tr_y = current_transform.translate_y;
9698
9699                    let make_glyph_instance =
9700                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 4]) {
9701                            if has_linear {
9702                                let gc_x = gx + gw * 0.5;
9703                                let gc_y = gy + gh * 0.5;
9704                                let wc_x = lin[0] * gc_x + lin[1] * gc_y + tr_x;
9705                                let wc_y = lin[2] * gc_x + lin[3] * gc_y + tr_y;
9706                                let ww = gw * current_transform.scale_x;
9707                                let wh = gh * current_transform.scale_y;
9708                                let ex = fwd[0].abs() * ww * 0.5 + fwd[1].abs() * wh * 0.5;
9709                                let ey = fwd[2].abs() * ww * 0.5 + fwd[3].abs() * wh * 0.5;
9710                                let ndc_tl = to_ndc(
9711                                    wc_x - ex,
9712                                    wc_y - ey,
9713                                    ex * 2.0,
9714                                    ey * 2.0,
9715                                    current_target_size.0,
9716                                    current_target_size.1,
9717                                );
9718                                let ndc = [
9719                                    ndc_tl[0] + ndc_tl[2] * 0.5,
9720                                    ndc_tl[1] + ndc_tl[3] * 0.5,
9721                                    ndc_tl[2],
9722                                    ndc_tl[3],
9723                                ];
9724                                (ndc, fwd)
9725                            } else {
9726                                let (sx, sy) = if current_transform.scale_x == 1.0
9727                                    && current_transform.scale_y == 1.0
9728                                {
9729                                    (gx.round(), gy.round())
9730                                } else {
9731                                    (gx, gy)
9732                                };
9733                                rect_to_instance_ndc(
9734                                    repose_core::Rect {
9735                                        x: sx,
9736                                        y: sy,
9737                                        w: gw,
9738                                        h: gh,
9739                                    },
9740                                    current_transform,
9741                                    current_target_size.0,
9742                                    current_target_size.1,
9743                                )
9744                            }
9745                        };
9746
9747                    let baseline_shift_y: f32 = px * baseline_shift.0;
9748
9749                    let (
9750                        draws_fill,
9751                        is_stroke,
9752                        stroke_width,
9753                        stroke_cap,
9754                        stroke_join,
9755                        stroke_miter,
9756                        stroke_path_effect,
9757                        stroke_color,
9758                    ) = match draw_style {
9759                        repose_core::DrawStyle::Stroke {
9760                            width,
9761                            cap,
9762                            join,
9763                            miter,
9764                            path_effect,
9765                        } => (
9766                            false,
9767                            true,
9768                            *width,
9769                            *cap,
9770                            *join,
9771                            *miter,
9772                            path_effect.clone(),
9773                            None,
9774                        ),
9775                        repose_core::DrawStyle::FillAndStroke {
9776                            width,
9777                            cap,
9778                            join,
9779                            miter,
9780                            path_effect,
9781                            outline_color,
9782                        } => (
9783                            true,
9784                            true,
9785                            *width,
9786                            *cap,
9787                            *join,
9788                            *miter,
9789                            path_effect.clone(),
9790                            // The outline is part of this text's appearance,
9791                            // so it fades with the text alpha like the fill.
9792                            // Without this a faded label keeps a solid halo.
9793                            outline_color.map(|c| {
9794                                c.with_alpha_f32(
9795                                    f32::from(c.3) * f32::from(color.3) / (255.0 * 255.0),
9796                                )
9797                            }),
9798                        ),
9799                        _ => (
9800                            true,
9801                            false,
9802                            0.0,
9803                            repose_core::StrokeCap::Butt,
9804                            repose_core::StrokeJoin::Miter,
9805                            4.0,
9806                            None,
9807                            None,
9808                        ),
9809                    };
9810                    // A contrasting outline reads as a halo, so it goes under
9811                    // the fill; a same-colour stroke is faux-bold and stays on
9812                    // top.
9813                    let outline_under = stroke_color.is_some();
9814                    let stroke_tess_key = if is_stroke {
9815                        Some(slug::StrokeTessKey::new(
9816                            stroke_width,
9817                            stroke_cap,
9818                            stroke_join,
9819                            stroke_miter,
9820                            &stroke_path_effect,
9821                        ))
9822                    } else {
9823                        None
9824                    };
9825
9826                    let mut legacy_shaped = None;
9827                    for (glyph_index, sg) in shaped.glyphs.iter().enumerate() {
9828                        if self.slug_enabled {
9829                            let ck = &sg.cache_key;
9830                            let color_linear = color.to_linear();
9831                            let stroke_color_linear = stroke_color.unwrap_or(*color).to_linear();
9832                            let glyph_pos = (rect.x + sg.x, rect.y + sg.y + baseline_shift_y);
9833                            let fill_key = draws_fill.then(|| {
9834                                Self::slug_draw_key(
9835                                    ck,
9836                                    true,
9837                                    None,
9838                                    current_transform,
9839                                    glyph_pos,
9840                                    px,
9841                                    current_target_size,
9842                                    color_linear,
9843                                )
9844                            });
9845                            let stroke_key = is_stroke.then(|| {
9846                                Self::slug_draw_key(
9847                                    ck,
9848                                    false,
9849                                    stroke_tess_key.as_ref(),
9850                                    current_transform,
9851                                    glyph_pos,
9852                                    px,
9853                                    current_target_size,
9854                                    stroke_color_linear,
9855                                )
9856                            });
9857
9858                            // The draw cache outlives the frame, so probing both keys up
9859                            // front lets a steady-state glyph clone neither buffer.
9860                            let fill_cache = fill_key
9861                                .as_ref()
9862                                .and_then(|k| self.get_cached_slug_vertices(k));
9863                            let stroke_cache = stroke_key
9864                                .as_ref()
9865                                .and_then(|k| self.get_cached_slug_vertices(k));
9866                            let fill_miss = fill_key.is_some() && fill_cache.is_none();
9867                            let stroke_miss = stroke_key.is_some() && stroke_cache.is_none();
9868                            if (fill_key.is_some() || stroke_key.is_some())
9869                                && !fill_miss
9870                                && !stroke_miss
9871                            {
9872                                let mut push = |cached: &Option<Arc<[slug::TessVertex]>>| {
9873                                    if let Some(vertices) = cached {
9874                                        slug_verts_local.extend_from_slice(vertices);
9875                                    }
9876                                };
9877                                if outline_under {
9878                                    push(&stroke_cache);
9879                                    push(&fill_cache);
9880                                } else {
9881                                    push(&fill_cache);
9882                                    push(&stroke_cache);
9883                                }
9884                                continue;
9885                            }
9886
9887                            let outline = self.cached_glyph_outline(ck);
9888                            if let Some(commands) = outline.as_ref() {
9889                                let font_size_px = f32::from_bits(ck.font_size_bits);
9890                                if draws_fill {
9891                                    self.slug_cache.get_or_insert(
9892                                        ck.clone(),
9893                                        font_size_px,
9894                                        commands.as_ref(),
9895                                    );
9896                                }
9897                                if is_stroke {
9898                                    self.slug_cache.get_or_insert_stroke(
9899                                        ck.clone(),
9900                                        font_size_px,
9901                                        commands.as_ref(),
9902                                        stroke_width,
9903                                        stroke_cap,
9904                                        stroke_join,
9905                                        stroke_miter,
9906                                        &stroke_path_effect,
9907                                    );
9908                                }
9909                            }
9910
9911                            if let Some(entry) = self.slug_cache.get(ck) {
9912                                let ox = rect.x + sg.x;
9913                                let oy = rect.y + sg.y + baseline_shift_y;
9914                                let tf = |x: f32, y: f32| -> (f32, f32) {
9915                                    if has_linear {
9916                                        (
9917                                            lin[0] * x + lin[1] * y + current_transform.translate_x,
9918                                            lin[2] * x + lin[3] * y + current_transform.translate_y,
9919                                        )
9920                                    } else {
9921                                        (
9922                                            x * current_transform.scale_x
9923                                                + current_transform.translate_x,
9924                                            y * current_transform.scale_y
9925                                                + current_transform.translate_y,
9926                                        )
9927                                    }
9928                                };
9929                                let tw = current_target_size.0;
9930                                let th = current_target_size.1;
9931                                let fill_vertices = if draws_fill && fill_cache.is_none() {
9932                                    entry.fill_vertices.clone().unwrap_or_default()
9933                                } else {
9934                                    Vec::new()
9935                                };
9936                                let stroke_vertices = if stroke_cache.is_none() {
9937                                    stroke_tess_key
9938                                        .as_ref()
9939                                        .and_then(|key| entry.stroke_variants.get(key))
9940                                        .map(|variant| variant.vertices.clone())
9941                                        .unwrap_or_default()
9942                                } else {
9943                                    Vec::new()
9944                                };
9945                                let mut emit =
9946                                    |verts: &Vec<[f32; 2]>,
9947                                     key: &Option<SlugDrawKey>,
9948                                     cached: &Option<Arc<[slug::TessVertex]>>,
9949                                     vertex_color: [f32; 4]| {
9950                                        if let Some(vertices) = cached {
9951                                            slug_verts_local.extend_from_slice(vertices);
9952                                            return;
9953                                        }
9954                                        let Some(key) = key.clone() else {
9955                                            return;
9956                                        };
9957                                        let transformed: Vec<slug::TessVertex> = verts
9958                                            .iter()
9959                                            .map(|v| {
9960                                                let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
9961                                                slug::TessVertex {
9962                                                    ndc_pos: [
9963                                                        sx / tw * 2.0 - 1.0,
9964                                                        -(sy / th) * 2.0 + 1.0,
9965                                                    ],
9966                                                    color: vertex_color,
9967                                                }
9968                                            })
9969                                            .collect();
9970                                        let vertices = self.cached_slug_vertices(key, transformed);
9971                                        slug_verts_local.extend_from_slice(&vertices);
9972                                    };
9973                                // A contrasting outline reads as a halo and
9974                                // goes under the fill; a same-colour stroke is
9975                                // faux-bold and stays on top.
9976                                if outline_under {
9977                                    emit(
9978                                        &stroke_vertices,
9979                                        &stroke_key,
9980                                        &stroke_cache,
9981                                        stroke_color_linear,
9982                                    );
9983                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
9984                                } else {
9985                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
9986                                    emit(
9987                                        &stroke_vertices,
9988                                        &stroke_key,
9989                                        &stroke_cache,
9990                                        stroke_color_linear,
9991                                    );
9992                                }
9993                                continue;
9994                            }
9995                        }
9996
9997                        if !draws_fill {
9998                            continue;
9999                        }
10000                        if legacy_shaped.is_none() {
10001                            legacy_shaped = Some(self.legacy_shape_text_cached(
10002                                text,
10003                                px,
10004                                lh_ratio,
10005                                *font_family,
10006                                fw,
10007                                fs,
10008                                letter_spacing.0,
10009                                font_variation_settings.as_ref(),
10010                            ));
10011                        }
10012                        let Some(legacy_glyph) = legacy_shaped
10013                            .as_ref()
10014                            .and_then(|glyphs| glyphs.get(glyph_index))
10015                        else {
10016                            continue;
10017                        };
10018                        let gx = rect.x + legacy_glyph.x + legacy_glyph.bearing_x;
10019                        let gy =
10020                            rect.y + legacy_glyph.y - legacy_glyph.bearing_y + baseline_shift_y;
10021                        if let Some(info) =
10022                            self.upload_glyph_color(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10023                        {
10024                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10025                            batch.colors.push(GlyphInstance {
10026                                xywh: ndc,
10027                                uv: [info.u0, info.v1, info.u1, info.v0],
10028                                color: color.to_linear(),
10029                                fwd_mat,
10030                            });
10031                        } else if let Some(info) =
10032                            self.upload_glyph_mask(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10033                        {
10034                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10035                            batch.masks.push(GlyphInstance {
10036                                xywh: ndc,
10037                                uv: [info.u0, info.v1, info.u1, info.v0],
10038                                color: color.to_linear(),
10039                                fwd_mat,
10040                            });
10041                        }
10042                    }
10043
10044                    // Upload slug vertices if any
10045                    if !slug_verts_local.is_empty() {
10046                        let bytes = bytemuck::cast_slice(&slug_verts_local);
10047                        let byte_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
10048                        let Ok(count) = u32::try_from(slug_verts_local.len()) else {
10049                            slug_verts_local.clear();
10050                            continue;
10051                        };
10052                        if self
10053                            .slug_ring
10054                            .grow_to_fit(&self.device, encoder, byte_len)
10055                            .is_ok()
10056                            && let Ok(off) = self.slug_ring.alloc_write(&self.queue, bytes)
10057                        {
10058                            current_pass
10059                                .cmds
10060                                .push(Cmd::GlyphsVector { off, cnt: count });
10061                        }
10062                        slug_verts_local.clear();
10063                    }
10064
10065                    // Text decoration: underline / strikethrough
10066                    if (text_decoration.underline || text_decoration.strikethrough)
10067                        && let Some(baseline_y) = baseline_y
10068                    {
10069                        flush_batch!();
10070                        current_prim = Some("rect");
10071                        let deco_color = text_decoration.color.unwrap_or(*color);
10072                        let thickness = (px * 0.07).max(1.0);
10073
10074                        if text_decoration.underline {
10075                            let dy = baseline_y + px * 0.1;
10076                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10077                                repose_core::Rect {
10078                                    x: rect.x,
10079                                    y: dy,
10080                                    w: rect.w,
10081                                    h: thickness,
10082                                },
10083                                current_transform,
10084                                current_target_size.0,
10085                                current_target_size.1,
10086                            );
10087                            batch.rects.push(RectInstance {
10088                                xywh: ndc,
10089                                radii: [0.0; 4],
10090                                color0: deco_color.to_linear(),
10091                                color1: [0.0; 4],
10092                                fwd_mat,
10093                                grad: [0.0; 4],
10094                                bits: 0,
10095                                _pad: [0.0; 3],
10096                            });
10097                        }
10098                        if text_decoration.strikethrough {
10099                            let sy = baseline_y - px * 0.3;
10100                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10101                                repose_core::Rect {
10102                                    x: rect.x,
10103                                    y: sy,
10104                                    w: rect.w,
10105                                    h: thickness,
10106                                },
10107                                current_transform,
10108                                current_target_size.0,
10109                                current_target_size.1,
10110                            );
10111                            batch.rects.push(RectInstance {
10112                                xywh: ndc,
10113                                radii: [0.0; 4],
10114                                color0: deco_color.to_linear(),
10115                                color1: [0.0; 4],
10116                                fwd_mat,
10117                                grad: [0.0; 4],
10118                                bits: 0,
10119                                _pad: [0.0; 3],
10120                            });
10121                        }
10122                    }
10123                }
10124                SceneNode::Coverage {
10125                    rect,
10126                    handle,
10127                    color,
10128                } => {
10129                    flush_batch!();
10130                    // Unknown handles are skipped (same policy as images);
10131                    // the lookup also marks the tile used for eviction.
10132                    let Some((tile_w, tile_h)) = self.coverage_dimensions(*handle) else {
10133                        log::warn!("Coverage handle {handle} not found");
10134                        continue;
10135                    };
10136                    // The tile composites at its registered size; `rect`
10137                    // positions its top-left.
10138                    let draw_rect = repose_core::Rect {
10139                        x: rect.x,
10140                        y: rect.y,
10141                        w: tile_w as f32,
10142                        h: tile_h as f32,
10143                    };
10144                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10145                    if !visible(affine_aabb(current_transform, &draw_rect), clip) {
10146                        continue;
10147                    }
10148                    let (ndc_center, fwd_mat) = rect_to_instance_ndc(
10149                        draw_rect,
10150                        current_transform,
10151                        current_target_size.0,
10152                        current_target_size.1,
10153                    );
10154                    let inst = GlyphInstance {
10155                        xywh: ndc_center,
10156                        uv: [0.0, 1.0, 1.0, 0.0],
10157                        color: color.to_linear(),
10158                        fwd_mat,
10159                    };
10160                    if let Some((off, _)) =
10161                        self.glyph_color
10162                            .upload(&self.device, &self.queue, encoder, &[inst])
10163                    {
10164                        current_pass.cmds.push(Cmd::Coverage {
10165                            off,
10166                            cnt: 1,
10167                            handle: *handle,
10168                        });
10169                    }
10170                }
10171                SceneNode::PushClip { rect, radius, op } => {
10172                    flush_batch!();
10173
10174                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
10175                    let t_identity = Transform::identity();
10176                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10177                    let transformed = affine_aabb(current_transform, rect);
10178                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10179                    let next_scissor = if is_diff {
10180                        top
10181                    } else {
10182                        intersect(top, transformed)
10183                    };
10184                    scissor_stack.push(next_scissor);
10185                    let scissor = to_scissor(
10186                        &next_scissor,
10187                        current_target_size.0 as u32,
10188                        current_target_size.1 as u32,
10189                    );
10190                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10191                    let mut blocked =
10192                        current_pass.active_scissor.is_none() || (has_inverse && !is_diff);
10193                    if has_inverse && is_diff {
10194                        log::error!(
10195                            "unsupported clip nesting: Difference cannot contain a Difference clip"
10196                        );
10197                        blocked = true;
10198                    }
10199                    let has_area = transformed.x.is_finite()
10200                        && transformed.y.is_finite()
10201                        && transformed.w.is_finite()
10202                        && transformed.h.is_finite()
10203                        && transformed.w > 0.0
10204                        && transformed.h > 0.0;
10205                    let mut applied = false;
10206                    let mut off = 0;
10207                    if !blocked && has_area && (is_diff || scissor.2 > 0 && scissor.3 > 0) {
10208                        let clip_ndc_tl = to_ndc(
10209                            transformed.x,
10210                            transformed.y,
10211                            transformed.w,
10212                            transformed.h,
10213                            current_target_size.0,
10214                            current_target_size.1,
10215                        );
10216                        let inst = ClipInstance {
10217                            xywh: [
10218                                clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
10219                                clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
10220                                clip_ndc_tl[2],
10221                                clip_ndc_tl[3],
10222                            ],
10223                            radii: radius.map(|r| r.0),
10224                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10225                        };
10226                        let bytes = bytemuck::bytes_of(&inst);
10227                        if self
10228                            .clip_ring
10229                            .grow_to_fit(
10230                                &self.device,
10231                                encoder,
10232                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
10233                            )
10234                            .is_ok()
10235                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
10236                        {
10237                            off = new_off;
10238                            applied = true;
10239                            current_pass.cmds.push(Cmd::ClipPush {
10240                                off,
10241                                cnt: 1,
10242                                scissor,
10243                                difference: is_diff,
10244                                applied,
10245                            });
10246                        } else {
10247                            blocked = !is_diff;
10248                        }
10249                    } else if !is_diff {
10250                        blocked = true;
10251                    }
10252                    current_pass.active_scissor = if blocked {
10253                        None
10254                    } else {
10255                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10256                    };
10257                    active_clips.push(ActiveClip::Rect {
10258                        off,
10259                        cnt: u32::from(applied),
10260                        rect: transformed,
10261                        radii: radius.map(|r| r.0),
10262                        scissor,
10263                        difference: is_diff,
10264                        applied,
10265                        blocked,
10266                    });
10267                }
10268                SceneNode::PopClip => {
10269                    flush_batch!();
10270
10271                    if scissor_stack.is_empty() || active_clips.is_empty() {
10272                        log::error!("PopClip does not match an active clip");
10273                    } else {
10274                        scissor_stack.pop();
10275                    }
10276                    let clip = active_clips.pop();
10277                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10278                    let scissor = to_scissor(
10279                        &top,
10280                        current_target_size.0 as u32,
10281                        current_target_size.1 as u32,
10282                    );
10283                    current_pass.active_scissor =
10284                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10285                    match clip {
10286                        Some(ActiveClip::Rect {
10287                            off,
10288                            cnt,
10289                            scissor: stencil_scissor,
10290                            difference,
10291                            applied,
10292                            ..
10293                        }) if applied && cnt > 0 => {
10294                            current_pass.cmds.push(Cmd::ClipPop {
10295                                off,
10296                                cnt,
10297                                scissor,
10298                                stencil_scissor,
10299                                difference,
10300                                applied: true,
10301                            });
10302                        }
10303                        Some(ActiveClip::Vector { .. }) => {
10304                            log::error!("PopClip matched a vector clip");
10305                        }
10306                        None => {}
10307                        _ => {}
10308                    }
10309                }
10310                SceneNode::Shadow {
10311                    rect,
10312                    radius,
10313                    elevation: _,
10314                    color,
10315                } => {
10316                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10317                    if !visible(affine_aabb(current_transform, rect), clip) {
10318                        continue;
10319                    }
10320                    flush_if_prim_changed!("rect", &self.rects);
10321                    let (ndc, fwd_mat) = rect_to_instance_ndc(
10322                        *rect,
10323                        current_transform,
10324                        current_target_size.0,
10325                        current_target_size.1,
10326                    );
10327                    let (_brush_type, color0, _color1, _grad_p0, _grad_p1) =
10328                        brush_to_instance_fields(&Brush::Solid(*color));
10329                    batch.rects.push(RectInstance {
10330                        xywh: ndc,
10331                        radii: radius.map(|r| r.0),
10332                        color0,
10333                        color1: [0.0; 4],
10334                        fwd_mat,
10335                        grad: [0.0; 4],
10336                        bits: 0,
10337                        _pad: [0.0; 3],
10338                    });
10339                }
10340                SceneNode::PushTransform { transform } => {
10341                    flush_batch!(); // flush before transform change
10342                    if transform.has_perspective() {
10343                        // True perspective cannot ride the affine fast path:
10344                        // flatten the subtree into an offscreen layer and
10345                        // composite it back projectively (CSS-style). See
10346                        // `push_perspective_layer`.
10347                        let top = *transform_stack.last().unwrap_or(&t_identity);
10348                        self.push_perspective_layer(
10349                            *transform,
10350                            top,
10351                            &mut transform_stack,
10352                            &mut scissor_stack,
10353                            &mut root_clip_rect,
10354                            &mut current_target_size,
10355                            &mut current_pass,
10356                            &mut active_clips,
10357                            &mut passes,
10358                            &mut target_stack,
10359                            &mut flatten_stack,
10360                            &mut flatten_id_head,
10361                            &mut next_pass_id,
10362                            &mut flatten_ids_used,
10363                            encoder,
10364                        );
10365                    } else {
10366                        let combined = current_transform.combine(transform);
10367                        transform_stack.push(combined);
10368                    }
10369                }
10370                SceneNode::PopTransform => {
10371                    flush_batch!(); // flush before transform change
10372                    if let Some(rec) = flatten_stack.last() {
10373                        // A flatten level closes when the stack is back to the
10374                        // two entries this flatten pushed (stripped transform +
10375                        // layer-local shift); deeper plain pushes close first.
10376                        if transform_stack.len() == rec.stack_len + 2 {
10377                            let rec = flatten_stack.pop().expect("checked above");
10378                            transform_stack.pop();
10379                            transform_stack.pop();
10380                            self.pop_perspective_layer(
10381                                rec,
10382                                &mut scissor_stack,
10383                                &mut root_clip_rect,
10384                                &mut current_target_size,
10385                                &mut current_pass,
10386                                &mut active_clips,
10387                                &mut passes,
10388                                &mut target_stack,
10389                                &mut next_pass_id,
10390                                encoder,
10391                            );
10392                            continue;
10393                        }
10394                    }
10395                    transform_stack.pop();
10396                }
10397                SceneNode::BeginLayer {
10398                    rect,
10399                    layer_id,
10400                    alpha,
10401                    blur_radius_x,
10402                    blur_radius_y,
10403                    rectangle_edge,
10404                } => {
10405                    flush_batch!();
10406                    let width_f = rect.w.round();
10407                    let height_f = rect.h.round();
10408                    let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
10409                    if !width_f.is_finite()
10410                        || !height_f.is_finite()
10411                        || width_f < 1.0
10412                        || height_f < 1.0
10413                        || width_f > max_dimension
10414                        || height_f > max_dimension
10415                    {
10416                        log::warn!("BeginLayer dimensions are invalid");
10417                        continue;
10418                    }
10419                    let width = width_f as u32;
10420                    let height = height_f as u32;
10421                    let parent_scissors =
10422                        std::mem::replace(&mut scissor_stack, Vec::with_capacity(8));
10423                    let parent_root_clip = std::mem::replace(
10424                        &mut root_clip_rect,
10425                        repose_core::Rect {
10426                            x: 0.0,
10427                            y: 0.0,
10428                            w: width as f32,
10429                            h: height as f32,
10430                        },
10431                    );
10432                    scissor_stack.push(root_clip_rect);
10433                    let layer_pass_id = next_pass_id;
10434                    next_pass_id += 1;
10435                    let parent_clips = std::mem::take(&mut active_clips);
10436                    let mut layer_pass = Pass {
10437                        id: layer_pass_id,
10438                        target: PassTarget::Layer(*layer_id),
10439                        initial_scissor: (0, 0, width, height),
10440                        active_scissor: Some((0, 0, width, height)),
10441                        clear_color: Some([0.0, 0.0, 0.0, 0.0]),
10442                        cmds: Vec::new(),
10443                    };
10444                    let parent_transform = *current_transform;
10445                    active_clips = self.replay_active_clips(
10446                        &parent_clips,
10447                        &parent_transform,
10448                        (rect.x, rect.y),
10449                        (width as f32, height as f32),
10450                        &mut layer_pass,
10451                        encoder,
10452                    );
10453                    let saved = std::mem::replace(&mut current_pass, layer_pass);
10454                    if let Some(top) = transform_stack.last_mut() {
10455                        *top = Transform::identity();
10456                    }
10457                    layer_stack.push(LayerState {
10458                        layer_id: *layer_id,
10459                        parent_target: saved.target,
10460                        parent_scissors,
10461                        parent_root_clip,
10462                        parent_size: current_target_size,
10463                        parent_transform,
10464                        parent_scissor: saved.active_scissor,
10465                        parent_clips,
10466                        alpha: *alpha,
10467                        blur: (blur_radius_x.0, blur_radius_y.0),
10468                        rectangle_edge: *rectangle_edge,
10469                    });
10470                    passes.push(saved);
10471                    if self.get_or_create_layer(*layer_id, width, height, *rect, false) {
10472                        self.producer_layer_ids.insert(*layer_id);
10473                    }
10474                    current_target_size = (width as f32, height as f32);
10475                }
10476                SceneNode::EndLayer { layer_id } => {
10477                    flush_batch!();
10478                    if layer_stack.last().map(|state| state.layer_id) != Some(*layer_id) {
10479                        log::warn!("EndLayer {} does not match active layer", layer_id);
10480                        continue;
10481                    }
10482                    let state = layer_stack.pop().expect("checked above");
10483                    scissor_stack = state.parent_scissors;
10484                    root_clip_rect = state.parent_root_clip;
10485                    active_clips = state.parent_clips;
10486                    if let Some(top) = transform_stack.last_mut() {
10487                        *top = state.parent_transform;
10488                    }
10489                    current_target_size = state.parent_size;
10490                    let resumed_pass_id = next_pass_id;
10491                    next_pass_id += 1;
10492                    let saved = std::mem::replace(
10493                        &mut current_pass,
10494                        Pass {
10495                            id: resumed_pass_id,
10496                            target: state.parent_target,
10497                            initial_scissor: state.parent_scissor.unwrap_or((0, 0, 1, 1)),
10498                            active_scissor: state.parent_scissor,
10499                            clear_color: None,
10500                            cmds: Vec::new(),
10501                        },
10502                    );
10503                    passes.push(saved);
10504                    let Some(layer) = self.layer_pool.get(layer_id).cloned() else {
10505                        continue;
10506                    };
10507                    let layer_rect = repose_core::Rect {
10508                        x: layer.rect_px.0,
10509                        y: layer.rect_px.1,
10510                        w: layer.rect_px.2,
10511                        h: layer.rect_px.3,
10512                    };
10513                    let local_layer_rect = affine_aabb(&state.parent_transform, &layer_rect);
10514                    let (parent_width, parent_height) = state.parent_size;
10515                    let parent_clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10516                    let composite_rect = if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10517                        let pad_x = blur_pad(state.blur.0);
10518                        let pad_y = blur_pad(state.blur.1);
10519                        repose_core::Rect {
10520                            x: local_layer_rect.x - pad_x,
10521                            y: local_layer_rect.y - pad_y,
10522                            w: local_layer_rect.w + pad_x * 2.0,
10523                            h: local_layer_rect.h + pad_y * 2.0,
10524                        }
10525                    } else {
10526                        local_layer_rect
10527                    };
10528                    let shadow_follows = shadow_layer_ids.contains(layer_id);
10529                    if !visible(composite_rect, parent_clip) {
10530                        continue;
10531                    }
10532                    let mut composite = None;
10533                    if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10534                        flush_batch!();
10535                        let pad_x = blur_pad(state.blur.0);
10536                        let pad_y = blur_pad(state.blur.1);
10537                        let rect = repose_core::Rect {
10538                            x: local_layer_rect.x - pad_x,
10539                            y: local_layer_rect.y - pad_y,
10540                            w: local_layer_rect.w + pad_x * 2.0,
10541                            h: local_layer_rect.h + pad_y * 2.0,
10542                        };
10543                        let ndc =
10544                            to_ndc(rect.x, rect.y, rect.w, rect.h, parent_width, parent_height);
10545                        let edge_mode = if state.rectangle_edge { 0 } else { 1 };
10546                        let sigma = [blur_sigma(state.blur.0), blur_sigma(state.blur.1)];
10547                        if let Some(scratch_id) = self.emit_separable_blur(
10548                            *layer_id,
10549                            BLUR_SLOT_CONTENT,
10550                            sigma,
10551                            (pad_x, pad_y),
10552                            edge_mode,
10553                            false,
10554                            [0.0; 4],
10555                            &mut passes,
10556                            &mut current_pass,
10557                            &mut next_pass_id,
10558                            encoder,
10559                        ) {
10560                            let inst = BlurInstance {
10561                                xywh: [
10562                                    ndc[0] + ndc[2] * 0.5,
10563                                    ndc[1] + ndc[3] * 0.5,
10564                                    ndc[2],
10565                                    ndc[3],
10566                                ],
10567                                uv: [0.0, 0.0, 1.0, 1.0],
10568                                color: [1.0, 1.0, 1.0, state.alpha],
10569                                sigma_px: sigma,
10570                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10571                                axis: 1,
10572                                edge_mode,
10573                                shape: [0.0; 4],
10574                            };
10575                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10576                                composite = Some(Cmd::CompositeBlur {
10577                                    off,
10578                                    cnt: 1,
10579                                    layer_id: scratch_id,
10580                                });
10581                            }
10582                        }
10583                    }
10584                    // No scratch (transient budget exhausted) leaves the layer
10585                    // sharp rather than dropped.
10586                    if composite.is_none() {
10587                        let ndc = to_ndc(
10588                            local_layer_rect.x,
10589                            local_layer_rect.y,
10590                            local_layer_rect.w,
10591                            local_layer_rect.h,
10592                            parent_width,
10593                            parent_height,
10594                        );
10595                        let inst = GlyphInstance {
10596                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
10597                            uv: [0.0, 1.0, 1.0, 0.0],
10598                            color: [1.0, 1.0, 1.0, state.alpha],
10599                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10600                        };
10601                        composite = self
10602                            .glyph_color
10603                            .upload(&self.device, &self.queue, encoder, &[inst])
10604                            .map(|(off, cnt)| Cmd::CompositeLayer {
10605                                off,
10606                                cnt,
10607                                layer_id: *layer_id,
10608                            });
10609                    }
10610                    if let Some(command) = composite {
10611                        if shadow_follows {
10612                            pending_layer_composite = Some(command);
10613                        } else {
10614                            current_pass.cmds.push(command);
10615                        }
10616                    }
10617                }
10618                SceneNode::CompositeShadow {
10619                    layer_id,
10620                    blur_px,
10621                    offset_px,
10622                    color,
10623                    shape_px,
10624                } => {
10625                    let sigma = blur_sigma(blur_px.0);
10626                    let pad = blur_pad(blur_px.0);
10627                    let layer = self.layer_pool.get(layer_id).cloned();
10628                    if let Some(layer) = layer {
10629                        let layer_rect = repose_core::Rect {
10630                            x: layer.rect_px.0,
10631                            y: layer.rect_px.1,
10632                            w: layer.rect_px.2,
10633                            h: layer.rect_px.3,
10634                        };
10635                        let local_layer_rect =
10636                            affine_aabb(transform_stack.last().unwrap_or(&t_identity), &layer_rect);
10637                        let sx = local_layer_rect.x + offset_px.0.0 - pad;
10638                        let sy = local_layer_rect.y + offset_px.1.0 - pad;
10639                        let sw = local_layer_rect.w + pad * 2.0;
10640                        let sh = local_layer_rect.h + pad * 2.0;
10641                        let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10642                        if !visible(
10643                            repose_core::Rect {
10644                                x: sx,
10645                                y: sy,
10646                                w: sw,
10647                                h: sh,
10648                            },
10649                            clip,
10650                        ) {
10651                            flush_pending_layer!();
10652                            continue;
10653                        }
10654                        flush_batch!();
10655                        let shadow_color = color.to_linear();
10656                        let max_r = 0.5 * layer.rect_px.2.min(layer.rect_px.3).max(0.0);
10657                        let shape = shape_px.map(|r| r.0.clamp(0.0, max_r));
10658                        // Decal edges: a drop shadow's falloff lives outside the
10659                        // source's own bounds, so out-of-range taps must
10660                        // contribute nothing rather than clamp to the edge texel.
10661                        let scratch_id = self.emit_separable_blur(
10662                            *layer_id,
10663                            BLUR_SLOT_SHADOW,
10664                            [sigma, sigma],
10665                            (pad, pad),
10666                            1,
10667                            true,
10668                            shape,
10669                            &mut passes,
10670                            &mut current_pass,
10671                            &mut next_pass_id,
10672                            encoder,
10673                        );
10674                        if let Some(scratch_id) = scratch_id {
10675                            let ndc_tl = to_ndc(
10676                                sx,
10677                                sy,
10678                                sw,
10679                                sh,
10680                                current_target_size.0,
10681                                current_target_size.1,
10682                            );
10683                            let inst = BlurInstance {
10684                                xywh: [
10685                                    ndc_tl[0] + ndc_tl[2] * 0.5,
10686                                    ndc_tl[1] + ndc_tl[3] * 0.5,
10687                                    ndc_tl[2],
10688                                    ndc_tl[3],
10689                                ],
10690                                uv: [0.0, 0.0, 1.0, 1.0],
10691                                color: shadow_color,
10692                                sigma_px: [sigma, sigma],
10693                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10694                                axis: 1,
10695                                edge_mode: 1,
10696                                shape: [0.0; 4],
10697                            };
10698                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10699                                current_pass.cmds.push(Cmd::CompositeShadow {
10700                                    off,
10701                                    cnt: 1,
10702                                    layer_id: scratch_id,
10703                                });
10704                            }
10705                        }
10706                    }
10707                    flush_pending_layer!();
10708                }
10709                SceneNode::VectorMesh {
10710                    mesh,
10711                    transform,
10712                    paint,
10713                    clip: _,
10714                    blend,
10715                } => {
10716                    let affine = combine_mesh_affine(current_transform, *transform);
10717                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10718                    if !visible(mesh_aabb(mesh, affine), clip) {
10719                        continue;
10720                    }
10721                    flush_batch!();
10722                    let paint_alpha = match paint {
10723                        repose_core::PaintDesc::Linear {
10724                            start_color,
10725                            end_color,
10726                            ..
10727                        }
10728                        | repose_core::PaintDesc::Radial {
10729                            start_color,
10730                            end_color,
10731                            ..
10732                        }
10733                        | repose_core::PaintDesc::Sweep {
10734                            start_color,
10735                            end_color,
10736                            ..
10737                        } => Some(start_color.3.min(end_color.3) as f32 / 255.0),
10738                        repose_core::PaintDesc::LinearStops { stops, .. }
10739                        | repose_core::PaintDesc::RadialStops { stops, .. } => {
10740                            Some(stops.min_alpha())
10741                        }
10742                        _ => None,
10743                    };
10744                    let translucent_fixed_blend =
10745                        matches!(
10746                            blend,
10747                            repose_core::BlendMode::Add
10748                                | repose_core::BlendMode::Multiply
10749                                | repose_core::BlendMode::Screen
10750                                | repose_core::BlendMode::Darken
10751                                | repose_core::BlendMode::Lighten
10752                        ) && (mesh.vertices.iter().any(|vertex| vertex.color[3] < 0.9999)
10753                            || paint_alpha.is_some_and(|alpha| alpha < 0.9999));
10754                    if blend.needs_isolation() || translucent_fixed_blend {
10755                        let blend_scissor = to_scissor(
10756                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
10757                            current_target_size.0 as u32,
10758                            current_target_size.1 as u32,
10759                        );
10760                        self.emit_isolated_blend(
10761                            mesh.clone(),
10762                            *transform,
10763                            *paint,
10764                            *blend,
10765                            current_transform,
10766                            &mut current_pass,
10767                            &active_clips,
10768                            &mut passes,
10769                            &mut flatten_id_head,
10770                            &mut next_pass_id,
10771                            &mut flatten_ids_used,
10772                            blend_scissor,
10773                            encoder,
10774                            fb_w,
10775                            fb_h,
10776                        );
10777                    } else {
10778                        let t_identity = Transform::identity();
10779                        let current_transform = transform_stack.last().unwrap_or(&t_identity);
10780                        self.emit_vector_mesh(
10781                            current_transform,
10782                            mesh,
10783                            *transform,
10784                            paint,
10785                            *blend,
10786                            &mut current_pass.cmds,
10787                        );
10788                    }
10789                }
10790                SceneNode::VectorOverlay { meshes } => {
10791                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10792                    let has_visible = meshes
10793                        .iter()
10794                        .any(|mesh| visible(mesh_aabb(mesh, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip));
10795                    if !has_visible {
10796                        continue;
10797                    }
10798                    flush_batch!();
10799                    for (index, m) in meshes.iter().enumerate() {
10800                        if !visible(mesh_aabb(m, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip) {
10801                            continue;
10802                        }
10803                        let Some(mesh_gpu) = self.upload_overlay_mesh(meshes, index) else {
10804                            continue;
10805                        };
10806                        let Some(uoff) = self.alloc_mesh_uniform(MeshUniform::identity()) else {
10807                            continue;
10808                        };
10809                        current_pass.cmds.push(Cmd::VectorOverlay {
10810                            mesh: mesh_gpu,
10811                            uoff,
10812                        });
10813                    }
10814                }
10815                SceneNode::PushVectorClip { mesh, op } => {
10816                    flush_batch!();
10817                    let difference = matches!(op, repose_core::ClipOp::Difference);
10818                    let t_identity = Transform::identity();
10819                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10820                    let affine =
10821                        combine_mesh_affine(current_transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
10822                    let aabb = mesh_aabb(mesh, affine);
10823                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10824                    let next = if difference {
10825                        top
10826                    } else {
10827                        intersect(top, aabb)
10828                    };
10829                    scissor_stack.push(next);
10830                    let scissor = to_scissor(
10831                        &next,
10832                        current_target_size.0 as u32,
10833                        current_target_size.1 as u32,
10834                    );
10835                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10836                    let mut blocked = current_pass.active_scissor.is_none() || has_inverse;
10837                    if has_inverse {
10838                        log::error!(
10839                            "unsupported clip nesting: a Difference clip cannot contain another vector clip"
10840                        );
10841                    }
10842                    let has_area = aabb.x.is_finite()
10843                        && aabb.y.is_finite()
10844                        && aabb.w.is_finite()
10845                        && aabb.h.is_finite()
10846                        && aabb.w > 0.0
10847                        && aabb.h > 0.0;
10848                    let mut applied = false;
10849                    let mut geometry = None;
10850                    let mut uoff = 0;
10851                    if !blocked
10852                        && has_area
10853                        && (if difference {
10854                            visible(aabb, top)
10855                        } else {
10856                            scissor.2 > 0 && scissor.3 > 0
10857                        })
10858                    {
10859                        if let Some(mesh_gpu) = self.upload_mesh_geometry(mesh)
10860                            && let Some(slot) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
10861                                affine,
10862                                &repose_core::PaintDesc::Solid,
10863                            ))
10864                        {
10865                            geometry = Some(mesh_gpu.clone());
10866                            uoff = slot;
10867                            applied = true;
10868                            current_pass.cmds.push(Cmd::VectorClipPush {
10869                                mesh: mesh_gpu,
10870                                uoff,
10871                                scissor,
10872                                difference,
10873                                applied,
10874                            });
10875                        } else {
10876                            blocked = !difference;
10877                        }
10878                    } else if !difference {
10879                        blocked = true;
10880                    }
10881                    current_pass.active_scissor = if blocked {
10882                        None
10883                    } else {
10884                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10885                    };
10886                    active_clips.push(ActiveClip::Vector {
10887                        mesh: geometry,
10888                        source: mesh.clone(),
10889                        uoff,
10890                        affine,
10891                        scissor,
10892                        difference,
10893                        applied,
10894                        blocked,
10895                    });
10896                }
10897                SceneNode::PopVectorClip => {
10898                    flush_batch!();
10899                    if scissor_stack.is_empty() || active_clips.is_empty() {
10900                        log::error!("PopVectorClip does not match an active clip");
10901                    } else {
10902                        scissor_stack.pop();
10903                    }
10904                    let clip = active_clips.pop();
10905                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10906                    let scissor = to_scissor(
10907                        &top,
10908                        current_target_size.0 as u32,
10909                        current_target_size.1 as u32,
10910                    );
10911                    current_pass.active_scissor =
10912                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10913                    match clip {
10914                        Some(ActiveClip::Vector {
10915                            mesh: Some(mesh),
10916                            uoff,
10917                            scissor: stencil_scissor,
10918                            difference,
10919                            applied,
10920                            ..
10921                        }) if applied && mesh.vertex_count > 0 && mesh.index_count > 0 => {
10922                            current_pass.cmds.push(Cmd::VectorClipPop {
10923                                mesh,
10924                                uoff,
10925                                scissor,
10926                                stencil_scissor,
10927                                difference,
10928                                applied: true,
10929                            });
10930                        }
10931                        Some(ActiveClip::Rect { .. }) => {
10932                            log::error!("PopVectorClip matched a rounded-rect clip");
10933                        }
10934                        None => {}
10935                        _ => {}
10936                    }
10937                }
10938                SceneNode::Callback { rect, payload } => {
10939                    let t = transform_stack
10940                        .last()
10941                        .copied()
10942                        .unwrap_or(Transform::identity());
10943                    let transformed = affine_aabb(&t, rect);
10944                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10945                    let clip_rect = intersect(transformed, top);
10946                    let scissor = to_scissor(
10947                        &clip_rect,
10948                        current_target_size.0 as u32,
10949                        current_target_size.1 as u32,
10950                    );
10951                    let restore_scissor = to_scissor(
10952                        &top,
10953                        current_target_size.0 as u32,
10954                        current_target_size.1 as u32,
10955                    );
10956                    if current_pass.active_scissor.is_none()
10957                        || !visible(transformed, top)
10958                        || scissor.2 == 0
10959                        || scissor.3 == 0
10960                    {
10961                        continue;
10962                    }
10963                    flush_batch!();
10964                    current_pass.cmds.push(Cmd::Callback {
10965                        rect: transformed,
10966                        clip_rect,
10967                        scissor,
10968                        restore_scissor,
10969                        callback_id: Arc::as_ptr(payload) as *const () as usize,
10970                        payload: payload.clone(),
10971                    });
10972                }
10973                _ => {}
10974            }
10975        }
10976
10977        flush_pending_layer!();
10978        flush_batch!();
10979        passes.push(current_pass);
10980
10981        {
10982            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
10983            let mut prepare_list: Vec<(usize, Arc<Callback>)> = Vec::new();
10984            for pass in &passes {
10985                for command in &pass.cmds {
10986                    if let Cmd::Callback { payload, .. } = command
10987                        && payload.downcast_ref::<Callback>().is_some()
10988                    {
10989                        let ptr = Arc::as_ptr(payload) as *const () as usize;
10990                        if seen.insert(ptr)
10991                            && let Ok(cb_arc) = payload.clone().downcast::<Callback>()
10992                        {
10993                            prepare_list.push((ptr, cb_arc));
10994                        }
10995                    }
10996                }
10997            }
10998            if !prepare_list.is_empty() {
10999                let mut callback_targets: HashMap<usize, Vec<(PassTarget, ScreenDescriptor)>> =
11000                    HashMap::new();
11001                for pass in &passes {
11002                    let (size, target_format, sample_count) = match pass.target {
11003                        PassTarget::Surface => (
11004                            [self.output_width, self.output_height],
11005                            if self.working_space {
11006                                wgpu::TextureFormat::Rgba16Float
11007                            } else {
11008                                self.output_format
11009                            },
11010                            self.active_surface_msaa_samples(),
11011                        ),
11012                        PassTarget::Layer(layer_id) => {
11013                            let layer = self.layer_pool.get(&layer_id);
11014                            (
11015                                layer.map_or([self.output_width, self.output_height], |layer| {
11016                                    [layer.width, layer.height]
11017                                }),
11018                                self.layer_target_format(),
11019                                1,
11020                            )
11021                        }
11022                    };
11023                    for command in &pass.cmds {
11024                        if let Cmd::Callback { payload, .. } = command {
11025                            let descriptor = ScreenDescriptor {
11026                                size_in_pixels: size,
11027                                pixels_per_point: self.pixels_per_point,
11028                                target_format,
11029                                sample_count,
11030                            };
11031                            let targets = callback_targets
11032                                .entry(Arc::as_ptr(payload) as *const () as usize)
11033                                .or_default();
11034                            if !targets.iter().any(|(target, target_desc)| {
11035                                *target == pass.target
11036                                    && target_desc.target_format == descriptor.target_format
11037                                    && target_desc.sample_count == descriptor.sample_count
11038                            }) {
11039                                targets.push((pass.target, descriptor));
11040                            }
11041                        }
11042                    }
11043                }
11044
11045                let default_descriptor = ScreenDescriptor {
11046                    size_in_pixels: [self.output_width, self.output_height],
11047                    pixels_per_point: self.pixels_per_point,
11048                    target_format: if self.working_space {
11049                        wgpu::TextureFormat::Rgba16Float
11050                    } else {
11051                        self.output_format
11052                    },
11053                    sample_count: self.active_surface_msaa_samples(),
11054                };
11055                let default_descriptors = [(PassTarget::Surface, default_descriptor)];
11056                let mut prepare_encoder =
11057                    self.device
11058                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11059                            label: Some("callback prepare"),
11060                        });
11061                let mut finish_encoder =
11062                    self.device
11063                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11064                            label: Some("callback finish prepare"),
11065                        });
11066                let mut active_callback_scopes = HashSet::new();
11067                for (key, cb) in &prepare_list {
11068                    let descriptors = callback_targets
11069                        .get(key)
11070                        .map(Vec::as_slice)
11071                        .unwrap_or(&default_descriptors);
11072                    for &(target, screen_desc) in descriptors {
11073                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11074                        active_callback_scopes.insert(scope.clone());
11075                        if !scope.callback.reusable
11076                            && self
11077                                .callback_scope_payloads
11078                                .get(&scope)
11079                                .is_some_and(|payload| payload.upgrade().is_none())
11080                        {
11081                            self.remove_callback_scope(&scope);
11082                        }
11083                        let payload: Weak<Callback> = Arc::downgrade(cb);
11084                        self.callback_scope_payloads.insert(scope, payload);
11085                    }
11086                }
11087                let mut prepare_buffers = Vec::new();
11088                let mut finish_buffers = Vec::new();
11089                for (key, cb) in &prepare_list {
11090                    let descriptors = callback_targets
11091                        .get(key)
11092                        .map(Vec::as_slice)
11093                        .unwrap_or(&default_descriptors);
11094                    for &(target, screen_desc) in descriptors {
11095                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11096                        self.touch_callback_scope(scope.clone());
11097                        if !self.callback_scoped_resources.contains_key(&scope)
11098                            && self.callback_scoped_resources.len() >= MAX_CALLBACK_SCOPES
11099                        {
11100                            self.evict_callback_scope(&active_callback_scopes);
11101                        }
11102                        let resources = self.callback_scoped_resources.entry(scope).or_default();
11103                        prepare_buffers.extend(cb.0.prepare(
11104                            &self.device,
11105                            &self.queue,
11106                            &mut prepare_encoder,
11107                            &screen_desc,
11108                            resources,
11109                        ));
11110                    }
11111                }
11112                for (key, cb) in &prepare_list {
11113                    let descriptors = callback_targets
11114                        .get(key)
11115                        .map(Vec::as_slice)
11116                        .unwrap_or(&default_descriptors);
11117                    for &(target, screen_desc) in descriptors {
11118                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11119                        self.touch_callback_scope(scope.clone());
11120                        if let Some(resources) = self.callback_scoped_resources.get_mut(&scope) {
11121                            finish_buffers.extend(cb.0.finish_prepare(
11122                                &self.device,
11123                                &self.queue,
11124                                &mut finish_encoder,
11125                                &screen_desc,
11126                                resources,
11127                            ));
11128                        }
11129                    }
11130                }
11131                let mut callback_buffers =
11132                    Vec::with_capacity(2 + prepare_buffers.len() + finish_buffers.len());
11133                callback_buffers.push(prepare_encoder.finish());
11134                callback_buffers.extend(prepare_buffers);
11135                callback_buffers.push(finish_encoder.finish());
11136                callback_buffers.extend(finish_buffers);
11137                self.queue.submit(callback_buffers);
11138            }
11139        }
11140
11141        let globals_bytes = std::mem::size_of::<Globals>() as u64;
11142        if !self.ensure_globals_staging(passes.len()) {
11143            return;
11144        }
11145        for (i, pass) in passes.iter().enumerate() {
11146            let (target_w, target_h) = match pass.target {
11147                PassTarget::Surface => (fb_w, fb_h),
11148                PassTarget::Layer(layer_id) => {
11149                    let lt = self.layer_pool.get(&layer_id);
11150                    (
11151                        lt.map_or(fb_w, |l| l.width as f32),
11152                        lt.map_or(fb_h, |l| l.height as f32),
11153                    )
11154                }
11155            };
11156            self.queue.write_buffer(
11157                &self.globals_staging,
11158                (i as u64) * globals_bytes,
11159                bytemuck::bytes_of(&make_globals(target_w, target_h)),
11160            );
11161        }
11162
11163        let bind_mask = self.atlas_bind_group_mask();
11164        let bind_color = self.atlas_bind_group_color();
11165        let mut clip_depth: u32 = 0;
11166        let mut clip_depth_stack: Vec<u32> = Vec::new();
11167
11168        let snapshot_source = target_texture.cloned();
11169        let last_surface_pass = passes
11170            .iter()
11171            .rposition(|pass| matches!(pass.target, PassTarget::Surface));
11172        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
11173            // Populate backdrop snapshots for blends composited in this
11174            // pass: copy the parent target region into the snapshot
11175            // texture. Passes execute in order, so the parent holds the
11176            // true backdrop. Surface parents need the target texture (no
11177            // swapchain sampling mid-frame); layer parents copy from the
11178            // pool. Either way the region is parent-target pixels. Copies
11179            // for other passes stay queued (`remaining`).
11180            let needs_snapshot = pass
11181                .cmds
11182                .iter()
11183                .any(|c| matches!(c, Cmd::BlendLayer { .. }));
11184            if needs_snapshot {
11185                let copies = std::mem::take(&mut self.blend_copies);
11186                let has_surface_copy = copies.iter().any(|copy| copy.target == PassTarget::Surface);
11187                let mut resolved_surface = None;
11188                if has_surface_copy
11189                    && !self.working_space
11190                    && let (Some(msaa_view), Some(resolve_view)) =
11191                        (self.msaa_view.as_ref(), self.surface_resolve_view.as_ref())
11192                {
11193                    let resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11194                        label: Some("surface blend resolve"),
11195                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11196                            view: msaa_view,
11197                            resolve_target: Some(resolve_view),
11198                            ops: wgpu::Operations {
11199                                load: wgpu::LoadOp::Load,
11200                                store: wgpu::StoreOp::Store,
11201                            },
11202                            depth_slice: None,
11203                        })],
11204                        depth_stencil_attachment: None,
11205                        timestamp_writes: None,
11206                        occlusion_query_set: None,
11207                        multiview_mask: None,
11208                    });
11209                    drop(resolve_pass);
11210                    resolved_surface = self.surface_resolve_tex.clone();
11211                }
11212                let mut remaining = Vec::with_capacity(copies.len());
11213                for copy in copies {
11214                    if copy.pass_id != pass.id {
11215                        remaining.push(copy);
11216                        continue;
11217                    }
11218                    let BlendCopy {
11219                        blend_id,
11220                        target,
11221                        region,
11222                        ..
11223                    } = copy;
11224                    let (src_tex, tw, th) = match target {
11225                        PassTarget::Layer(parent_id) => match self.layer_pool.get(&parent_id) {
11226                            Some(lt) => (lt.texture.clone(), lt.width, lt.height),
11227                            None => continue,
11228                        },
11229                        PassTarget::Surface => {
11230                            let texture = if self.working_space {
11231                                self.ws_tex.clone()
11232                            } else if self.msaa_view.is_some() {
11233                                resolved_surface.clone()
11234                            } else {
11235                                snapshot_source.clone()
11236                            };
11237                            match texture {
11238                                Some(texture) => (texture, self.output_width, self.output_height),
11239                                None => {
11240                                    self.remove_blend_snapshot(blend_id);
11241                                    continue;
11242                                }
11243                            }
11244                        }
11245                    };
11246                    let Some(dst_tex) = self.blend_snapshot_texture(blend_id) else {
11247                        continue;
11248                    };
11249                    if !src_tex.usage().contains(wgpu::TextureUsages::COPY_SRC)
11250                        || !dst_tex.usage().contains(wgpu::TextureUsages::COPY_DST)
11251                    {
11252                        self.remove_blend_snapshot(blend_id);
11253                        continue;
11254                    }
11255                    let Some((sx, sy, cw, ch)) = checked_copy_region(region, tw, th) else {
11256                        self.remove_blend_snapshot(blend_id);
11257                        continue;
11258                    };
11259                    encoder.copy_texture_to_texture(
11260                        wgpu::TexelCopyTextureInfo {
11261                            texture: &src_tex,
11262                            mip_level: 0,
11263                            origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
11264                            aspect: wgpu::TextureAspect::All,
11265                        },
11266                        wgpu::TexelCopyTextureInfo {
11267                            texture: &dst_tex,
11268                            mip_level: 0,
11269                            origin: wgpu::Origin3d::ZERO,
11270                            aspect: wgpu::TextureAspect::All,
11271                        },
11272                        wgpu::Extent3d {
11273                            width: cw,
11274                            height: ch,
11275                            depth_or_array_layers: 1,
11276                        },
11277                    );
11278                }
11279                self.blend_copies = remaining;
11280            }
11281            let (color_view, resolve_target, depth_stencil_view, is_layer, working_target) =
11282                match pass.target {
11283                    PassTarget::Surface => {
11284                        let swap_view = target_view.clone();
11285                        let use_ws = self.working_space && self.ws_view.is_some();
11286                        let (color, resolve) = if use_ws {
11287                            let ws_view = self.ws_view.as_ref().unwrap();
11288                            if let Some(msaa_view) = &self.ws_msaa_view {
11289                                (msaa_view.clone(), Some(ws_view.clone()))
11290                            } else {
11291                                (ws_view.clone(), None)
11292                            }
11293                        } else if let Some(msaa_view) = &self.msaa_view {
11294                            // Resolve once per frame. Graphics layers split the
11295                            // surface into several passes that all accumulate into
11296                            // the same multisampled attachment, so only the last
11297                            // one can contribute to the presented image.
11298                            let resolve =
11299                                (Some(pass_index) == last_surface_pass).then(|| swap_view.clone());
11300                            (msaa_view.clone(), resolve)
11301                        } else {
11302                            (swap_view, None)
11303                        };
11304                        (
11305                            color,
11306                            resolve,
11307                            self.depth_stencil_view.clone(),
11308                            false,
11309                            use_ws,
11310                        )
11311                    }
11312                    PassTarget::Layer(layer_id) => {
11313                        if let Some(lt) = self.layer_pool.get(&layer_id) {
11314                            (
11315                                lt.view.clone(),
11316                                None,
11317                                lt.depth_stencil_view.clone(),
11318                                true,
11319                                false,
11320                            )
11321                        } else {
11322                            log::warn!("missing layer target {layer_id}");
11323                            continue;
11324                        }
11325                    }
11326                };
11327
11328            encoder.copy_buffer_to_buffer(
11329                &self.globals_staging,
11330                (pass_index as u64) * globals_bytes,
11331                &self.globals_buf,
11332                0,
11333                globals_bytes,
11334            );
11335
11336            if is_layer {
11337                clip_depth_stack.push(clip_depth);
11338                clip_depth = 0;
11339            }
11340
11341            let (tw, th) = match pass.target {
11342                PassTarget::Surface => (self.output_width, self.output_height),
11343                PassTarget::Layer(layer_id) => self
11344                    .layer_pool
11345                    .get(&layer_id)
11346                    .map(|l| (l.width, l.height))
11347                    .unwrap_or((self.output_width, self.output_height)),
11348            };
11349            let initial_scissor = clamp_scissor(
11350                pass.initial_scissor.0,
11351                pass.initial_scissor.1,
11352                pass.initial_scissor.2,
11353                pass.initial_scissor.3,
11354                tw,
11355                th,
11356            );
11357            let mut active_scissor = pass
11358                .active_scissor
11359                .map(|scissor| clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th));
11360            if active_scissor.is_some_and(|scissor| scissor.2 == 0 || scissor.3 == 0) {
11361                active_scissor = None;
11362            }
11363
11364            let pipes: &Pipelines = if is_layer {
11365                if self.working_space {
11366                    &self.working_space_layer_pipes
11367                } else {
11368                    &self.layer_pipes
11369                }
11370            } else if working_target {
11371                &self.working_space_pipes
11372            } else {
11373                &self.surface_pipes
11374            };
11375
11376            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11377                label: Some("pass"),
11378                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11379                    view: &color_view,
11380                    resolve_target: resolve_target.as_ref(),
11381                    ops: wgpu::Operations {
11382                        load: match pass.clear_color {
11383                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
11384                                r: c[0] as f64,
11385                                g: c[1] as f64,
11386                                b: c[2] as f64,
11387                                a: c[3] as f64,
11388                            }),
11389                            None => wgpu::LoadOp::Load,
11390                        },
11391                        store: wgpu::StoreOp::Store,
11392                    },
11393                    depth_slice: None,
11394                })],
11395                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
11396                    view: &depth_stencil_view,
11397                    depth_ops: None,
11398                    stencil_ops: Some(wgpu::Operations {
11399                        load: if pass.clear_color.is_some() {
11400                            wgpu::LoadOp::Clear(STENCIL_BASE)
11401                        } else {
11402                            wgpu::LoadOp::Load
11403                        },
11404                        store: wgpu::StoreOp::Store,
11405                    }),
11406                }),
11407                timestamp_writes: None,
11408                occlusion_query_set: None,
11409                multiview_mask: None,
11410            });
11411
11412            rpass.set_bind_group(0, &self.globals_bind, &[]);
11413            rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11414            let initial_draw_scissor = if initial_scissor.2 == 0 || initial_scissor.3 == 0 {
11415                (0, 0, 1, 1)
11416            } else {
11417                initial_scissor
11418            };
11419            rpass.set_scissor_rect(
11420                initial_draw_scissor.0,
11421                initial_draw_scissor.1,
11422                initial_draw_scissor.2,
11423                initial_draw_scissor.3,
11424            );
11425
11426            macro_rules! draw_simple {
11427                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
11428                    rpass.set_pipeline($pipeline);
11429                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11430                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11431                    rpass.draw(0..6, 0..$n);
11432                }};
11433            }
11434
11435            macro_rules! draw_with_bind {
11436                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
11437                    rpass.set_pipeline($pipeline);
11438                    rpass.set_bind_group(1, $bind, &[]);
11439                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11440                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11441                    rpass.draw(0..6, 0..$n);
11442                }};
11443            }
11444
11445            macro_rules! draw_indexed_mesh {
11446                ($pipeline:expr, $uoff:ident, $mesh:ident) => {{
11447                    rpass.set_pipeline($pipeline);
11448                    rpass.set_bind_group(1, &self.mesh_bind, &[$uoff as u32]);
11449                    let vbytes =
11450                        ($mesh.vertex_count as u64) * std::mem::size_of::<MeshVertex>() as u64;
11451                    rpass.set_vertex_buffer(0, $mesh.vertices.slice(0..vbytes));
11452                    let ibytes = ($mesh.index_count as u64) * std::mem::size_of::<u32>() as u64;
11453                    rpass.set_index_buffer(
11454                        $mesh.indices.slice(0..ibytes),
11455                        wgpu::IndexFormat::Uint32,
11456                    );
11457                    rpass.draw_indexed(0..$mesh.index_count, 0, 0..1);
11458                }};
11459            }
11460
11461            for cmd in pass.cmds {
11462                if active_scissor.is_none()
11463                    && !matches!(
11464                        &cmd,
11465                        Cmd::ClipPush { .. }
11466                            | Cmd::ClipPop { .. }
11467                            | Cmd::VectorClipPush { .. }
11468                            | Cmd::VectorClipPop { .. }
11469                    )
11470                {
11471                    continue;
11472                }
11473                match cmd {
11474                    Cmd::ClipPush {
11475                        off,
11476                        cnt: n,
11477                        scissor,
11478                        difference,
11479                        applied,
11480                    } => {
11481                        let scissor =
11482                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11483                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11484                        if usable {
11485                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11486                            active_scissor = Some(scissor);
11487                            rpass.set_pipeline(if difference {
11488                                &pipes.clip_dec
11489                            } else {
11490                                &pipes.clip_bin
11491                            });
11492
11493                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11494                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11495                            rpass.draw(0..6, 0..n);
11496                            if !difference {
11497                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11498                            }
11499                        } else {
11500                            active_scissor = None;
11501                        }
11502                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11503                    }
11504
11505                    Cmd::ClipPop {
11506                        off,
11507                        cnt: n,
11508                        scissor,
11509                        stencil_scissor,
11510                        difference,
11511                        applied,
11512                    } => {
11513                        let scissor =
11514                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11515                        let dec = clamp_scissor(
11516                            stencil_scissor.0,
11517                            stencil_scissor.1,
11518                            stencil_scissor.2,
11519                            stencil_scissor.3,
11520                            tw,
11521                            th,
11522                        );
11523                        if scissor.2 > 0 && scissor.3 > 0 {
11524                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11525                            active_scissor = Some(scissor);
11526                        } else {
11527                            active_scissor = None;
11528                        }
11529                        if applied && n > 0 && dec.2 > 0 && dec.3 > 0 {
11530                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11531                            rpass.set_pipeline(if difference {
11532                                &pipes.clip_bin
11533                            } else {
11534                                &pipes.clip_dec
11535                            });
11536                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11537                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11538                            rpass.draw(0..6, 0..n);
11539                            if scissor.2 > 0 && scissor.3 > 0 {
11540                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11541                            }
11542                            if !difference {
11543                                clip_depth = clip_depth.saturating_sub(1);
11544                            }
11545                        }
11546                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11547                    }
11548
11549                    Cmd::Rect { off, cnt: n } => {
11550                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
11551                    }
11552
11553                    Cmd::Border { off, cnt: n } => {
11554                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
11555                    }
11556
11557                    Cmd::GlyphsMask { off, cnt: n } => {
11558                        draw_with_bind!(
11559                            &pipes.text_mask,
11560                            self.glyph_mask.ring,
11561                            GlyphInstance,
11562                            &bind_mask,
11563                            off,
11564                            n
11565                        );
11566                    }
11567
11568                    Cmd::GlyphsColor { off, cnt: n } => {
11569                        draw_with_bind!(
11570                            &pipes.text_color,
11571                            self.glyph_color.ring,
11572                            GlyphInstance,
11573                            &bind_color,
11574                            off,
11575                            n
11576                        );
11577                    }
11578
11579                    Cmd::GlyphsVector { off, cnt: n } => {
11580                        if let Some(slug_pipe) = pipes.slug.as_ref() {
11581                            rpass.set_pipeline(slug_pipe);
11582                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
11583                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
11584                            rpass.draw(0..n, 0..1);
11585                        }
11586                    }
11587
11588                    Cmd::ImageRgba {
11589                        off,
11590                        cnt: n,
11591                        handle,
11592                        filter,
11593                        tile,
11594                        blend,
11595                    } => {
11596                        let bind_opt = match self.images.get(&handle) {
11597                            Some(ImageTex::Rgba { binds, .. }) => Some(binds.get(filter, tile)),
11598                            Some(ImageTex::User { binds, .. }) => Some(binds.get(filter, tile)),
11599                            _ => None,
11600                        };
11601                        if let Some(bind) = bind_opt {
11602                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11603                            {
11604                                Some(index) => &pipes.image_blends[index],
11605                                None => &pipes.image_rgba,
11606                            };
11607                            draw_with_bind!(
11608                                pipe,
11609                                self.glyph_color.ring,
11610                                GlyphInstance,
11611                                bind,
11612                                off,
11613                                n
11614                            );
11615                        }
11616                    }
11617                    Cmd::Coverage {
11618                        off,
11619                        cnt: n,
11620                        handle,
11621                    } => {
11622                        if let Some(tile) = self.coverages.get(&handle) {
11623                            draw_with_bind!(
11624                                &pipes.coverage,
11625                                self.glyph_color.ring,
11626                                GlyphInstance,
11627                                &tile.bind,
11628                                off,
11629                                n
11630                            );
11631                        }
11632                    }
11633
11634                    Cmd::ImageNv12 {
11635                        off,
11636                        cnt: n,
11637                        handle,
11638                        filter,
11639                        blend,
11640                    } => {
11641                        if let Some(ImageTex::Nv12 { binds, .. }) = self.images.get(&handle) {
11642                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11643                            {
11644                                Some(index) => &pipes.image_nv12_blends[index],
11645                                None => &pipes.image_nv12,
11646                            };
11647                            draw_with_bind!(
11648                                pipe,
11649                                self.nv12.ring,
11650                                Nv12Instance,
11651                                binds.get(filter, false),
11652                                off,
11653                                n
11654                            );
11655                        }
11656                    }
11657
11658                    Cmd::Ellipse { off, cnt: n } => {
11659                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
11660                    }
11661
11662                    Cmd::EllipseBorder { off, cnt: n } => {
11663                        draw_simple!(
11664                            &pipes.ellipse_borders,
11665                            self.ellipse_borders.ring,
11666                            EllipseBorderInstance,
11667                            off,
11668                            n
11669                        );
11670                    }
11671
11672                    Cmd::Arc { off, cnt: n } => {
11673                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
11674                    }
11675
11676                    Cmd::CompositeLayer {
11677                        off,
11678                        cnt: n,
11679                        layer_id,
11680                    } => {
11681                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11682                            draw_with_bind!(
11683                                &pipes.image_rgba,
11684                                self.glyph_color.ring,
11685                                GlyphInstance,
11686                                &lt.bind,
11687                                off,
11688                                n
11689                            );
11690                        }
11691                    }
11692                    Cmd::CompositeShadow {
11693                        off,
11694                        cnt: n,
11695                        layer_id,
11696                    } => {
11697                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11698                            draw_with_bind!(
11699                                &pipes.blur,
11700                                self.blur_ring,
11701                                BlurInstance,
11702                                &lt.bind_linear,
11703                                off,
11704                                n
11705                            );
11706                        }
11707                    }
11708                    Cmd::CompositeBlur {
11709                        off,
11710                        cnt: n,
11711                        layer_id,
11712                    } => {
11713                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11714                            draw_with_bind!(
11715                                &pipes.blur_content,
11716                                self.blur_ring,
11717                                BlurInstance,
11718                                &lt.bind_linear,
11719                                off,
11720                                n
11721                            );
11722                        }
11723                    }
11724                    Cmd::CompositeProjective {
11725                        off,
11726                        cnt: n,
11727                        layer_id,
11728                    } => {
11729                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11730                            // The layer texture is sampled with the rgba
11731                            // (non-linear-filter) binding, like the sharp
11732                            // composite path.
11733                            draw_with_bind!(
11734                                &pipes.projective_layer,
11735                                self.projective_ring,
11736                                ProjectiveInstance,
11737                                &lt.bind,
11738                                off,
11739                                n
11740                            );
11741                        }
11742                    }
11743
11744                    Cmd::BlendLayer {
11745                        off,
11746                        cnt: n,
11747                        src_layer,
11748                        dst_layer,
11749                        parent,
11750                        scissor,
11751                    } => {
11752                        let src = self.layer_pool.get(&src_layer).cloned();
11753                        // Backdrop is the snapshot copied from the parent
11754                        // target before this pass opened (a texture cannot be
11755                        // sampled mid-pass while bound as a render target).
11756                        // A missing snapshot (surface parent without texture,
11757                        // evicted layer) skips the draw: the mesh vanishes
11758                        // rather than misrendering.
11759                        let dst = dst_layer.and_then(|id| self.blend_snapshots.get(&id).cloned());
11760                        let in_parent = match parent {
11761                            PassTarget::Surface => !is_layer,
11762                            PassTarget::Layer(id) => {
11763                                matches!(pass.target, PassTarget::Layer(pid) if pid == id)
11764                            }
11765                        };
11766                        if let (Some(src_lt), Some(dst_snap)) = (src, dst)
11767                            && in_parent
11768                        {
11769                            let bytes = (n as u64) * std::mem::size_of::<BlendInstance>() as u64;
11770                            rpass.set_pipeline(&pipes.blend_layer);
11771                            let scissor =
11772                                clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11773                            if scissor.2 > 0 && scissor.3 > 0 {
11774                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11775                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11776                                rpass.set_bind_group(1, &src_lt.bind, &[]);
11777                                rpass.set_bind_group(2, &dst_snap.bind, &[]);
11778                                rpass.set_vertex_buffer(
11779                                    0,
11780                                    self.blend_ring.buf.slice(off..off + bytes),
11781                                );
11782                                rpass.draw(0..6, 0..n);
11783                            }
11784                        }
11785                    }
11786
11787                    Cmd::VectorMesh { mesh, uoff, blend } => {
11788                        let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend) {
11789                            Some(index) => &pipes.mesh_blends[index],
11790                            None => &pipes.mesh,
11791                        };
11792                        draw_indexed_mesh!(pipe, uoff, mesh);
11793                    }
11794
11795                    Cmd::VectorOverlay { mesh, uoff } => {
11796                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11797                        draw_indexed_mesh!(&pipes.mesh_overlay, uoff, mesh);
11798                    }
11799
11800                    Cmd::VectorClipPush {
11801                        mesh,
11802                        uoff,
11803                        scissor,
11804                        difference,
11805                        applied,
11806                    } => {
11807                        let scissor =
11808                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11809                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11810                        if usable {
11811                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11812                            active_scissor = Some(scissor);
11813                            let pipe = if difference {
11814                                &pipes.mesh_clip_dec
11815                            } else {
11816                                &pipes.mesh_clip_inc
11817                            };
11818                            draw_indexed_mesh!(pipe, uoff, mesh);
11819                            if !difference {
11820                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11821                            }
11822                        } else {
11823                            active_scissor = None;
11824                        }
11825                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11826                    }
11827
11828                    Cmd::VectorClipPop {
11829                        mesh,
11830                        uoff,
11831                        scissor,
11832                        stencil_scissor,
11833                        difference,
11834                        applied,
11835                    } => {
11836                        let scissor =
11837                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11838                        let dec = clamp_scissor(
11839                            stencil_scissor.0,
11840                            stencil_scissor.1,
11841                            stencil_scissor.2,
11842                            stencil_scissor.3,
11843                            tw,
11844                            th,
11845                        );
11846                        if scissor.2 > 0 && scissor.3 > 0 {
11847                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11848                            active_scissor = Some(scissor);
11849                        } else {
11850                            active_scissor = None;
11851                        }
11852                        if applied && dec.2 > 0 && dec.3 > 0 {
11853                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11854                            let pipe = if difference {
11855                                &pipes.mesh_clip_inc
11856                            } else {
11857                                &pipes.mesh_clip_dec
11858                            };
11859                            draw_indexed_mesh!(pipe, uoff, mesh);
11860                            if scissor.2 > 0 && scissor.3 > 0 {
11861                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11862                            }
11863                            if !difference {
11864                                clip_depth = clip_depth.saturating_sub(1);
11865                            }
11866                        }
11867                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11868                    }
11869
11870                    Cmd::Callback {
11871                        rect,
11872                        clip_rect,
11873                        scissor,
11874                        restore_scissor,
11875                        callback_id,
11876                        payload,
11877                    } => {
11878                        if let Some(cb) = payload.downcast_ref::<Callback>() {
11879                            // Round INWARD. A callback may only paint inside its
11880                            // own rect, and a fullscreen pass draws a single
11881                            // clip-space quad spanning twice the viewport, so an
11882                            // outward-rounded viewport overshot the rect and the
11883                            // outward-rounded scissor let that overshoot through.
11884                            let vp_x = rect.x.ceil().max(0.0);
11885                            let vp_y = rect.y.ceil().max(0.0);
11886                            let vp_w = ((rect.x + rect.w).floor() - vp_x).max(1.0);
11887                            let vp_h = ((rect.y + rect.h).floor() - vp_y).max(1.0);
11888                            if vp_w > 0.0 && vp_h > 0.0 && clip_rect.w > 0.0 && clip_rect.h > 0.0 {
11889                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11890                                rpass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
11891                                let info = repose_core::PaintCallbackInfo {
11892                                    viewport: rect,
11893                                    clip_rect,
11894                                    pixels_per_point: self.pixels_per_point,
11895                                    screen_size_px: [tw, th],
11896                                };
11897                                let descriptor = ScreenDescriptor {
11898                                    size_in_pixels: [tw, th],
11899                                    pixels_per_point: self.pixels_per_point,
11900                                    target_format: if is_layer {
11901                                        self.layer_target_format()
11902                                    } else if working_target {
11903                                        wgpu::TextureFormat::Rgba16Float
11904                                    } else {
11905                                        self.output_format
11906                                    },
11907                                    sample_count: if is_layer {
11908                                        1
11909                                    } else {
11910                                        self.active_surface_msaa_samples()
11911                                    },
11912                                };
11913                                let scope =
11914                                    callback_scope_key(cb, callback_id, pass.target, &descriptor);
11915                                let resources = self
11916                                    .callback_scoped_resources
11917                                    .get(&scope)
11918                                    .unwrap_or(&self.callback_resources);
11919                                // A panicking callback must not skip the restore below:
11920                                // the viewport/scissor would stay clamped to
11921                                // the callback rect and every later draw in the
11922                                // frame would be clipped away. Requires
11923                                // unwinding panics; under `panic = "abort"`
11924                                // nothing runs the restore.
11925                                let outcome = {
11926                                    let mut callback_pass = CallbackRenderPass::new(
11927                                        &mut rpass,
11928                                        descriptor.target_format,
11929                                        descriptor.sample_count,
11930                                    );
11931                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11932                                        cb.0.paint(info, &mut callback_pass, resources)
11933                                    }))
11934                                };
11935                                if outcome.is_err() {
11936                                    log::warn!("paint callback panicked; state restored");
11937                                }
11938                                rpass.set_viewport(0.0, 0.0, tw as f32, th as f32, 0.0, 1.0);
11939                                let restore = if restore_scissor.2 > 0 && restore_scissor.3 > 0 {
11940                                    restore_scissor
11941                                } else {
11942                                    (0, 0, 1, 1)
11943                                };
11944                                rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
11945                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11946                                rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11947                            }
11948                        } else {
11949                            log::warn!("Unknown paint callback payload");
11950                        }
11951                    }
11952                }
11953            }
11954            if is_layer {
11955                clip_depth = clip_depth_stack.pop().unwrap_or(0);
11956            }
11957        }
11958
11959        let transient_ids: HashSet<u32> = flatten_ids_used.iter().copied().collect();
11960        let producer_ids = std::mem::take(&mut self.producer_layer_ids);
11961        // A blur scratch stays bound while its source layer is live, so a stable
11962        // layer reuses its allocation instead of reallocating each frame. Once
11963        // the source is swept the scratch id is forgotten and goes with it.
11964        self.blur_scratch_ids
11965            .retain(|&(source, _), _| producer_ids.contains(&source));
11966        let live_scratch: HashSet<u32> = self.blur_scratch_ids.values().copied().collect();
11967        let stale_layers: Vec<u32> = self
11968            .layer_pool
11969            .keys()
11970            .filter_map(|id| {
11971                (!producer_ids.contains(id)
11972                    && !transient_ids.contains(id)
11973                    && !live_scratch.contains(id))
11974                .then_some(*id)
11975            })
11976            .collect();
11977        for id in stale_layers {
11978            if let Some(layer) = self.layer_pool.remove(&id) {
11979                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
11980                if layer.transient {
11981                    self.transient_layer_bytes_total =
11982                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
11983                }
11984            }
11985        }
11986        self.flatten_layer_ids = flatten_ids_used;
11987
11988        // Display pass: linear working space -> sRGB OETF -> swapchain
11989        if self.working_space
11990            && let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
11991                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
11992        {
11993            let swap_view = target_view.clone();
11994            let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11995                label: Some("display transform"),
11996                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11997                    view: &swap_view,
11998                    resolve_target: None,
11999                    ops: wgpu::Operations {
12000                        load: wgpu::LoadOp::Load,
12001                        store: wgpu::StoreOp::Store,
12002                    },
12003                    depth_slice: None,
12004                })],
12005                depth_stencil_attachment: None,
12006                timestamp_writes: None,
12007                occlusion_query_set: None,
12008                multiview_mask: None,
12009            });
12010            display_pass.set_pipeline(display_pipeline);
12011            display_pass.set_bind_group(1, ws_bind, &[]);
12012            display_pass.draw(0..3, 0..1);
12013        }
12014        self.recycle_blend_snapshots();
12015    }
12016
12017    /// Render a scene into an externally-provided texture view.
12018    /// Use this when embedding Repose in a host that owns the GPU.
12019    /// The host is responsible for submitting the encoder and handling present.
12020    pub fn render_to_view(
12021        &mut self,
12022        scene: &Scene,
12023        encoder: &mut wgpu::CommandEncoder,
12024        target_view: &wgpu::TextureView,
12025        width: u32,
12026        height: u32,
12027        clear_color: Option<[f64; 4]>,
12028    ) {
12029        self.resize(width, height);
12030
12031        if let Err(error) = self.begin_frame() {
12032            log::error!("render_to_view: {error:#}");
12033            return;
12034        }
12035
12036        if self.output_width == 0 || self.output_height == 0 {
12037            self.end_frame();
12038            return;
12039        }
12040
12041        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
12042        self.end_frame();
12043    }
12044}
12045
12046#[cfg(target_os = "linux")]
12047fn validate_dmabuf_plane(
12048    fd: &std::os::unix::io::OwnedFd,
12049    offset: u64,
12050    stride: u32,
12051    width: u32,
12052    height: u32,
12053    bytes_per_pixel: u32,
12054) -> anyhow::Result<u64> {
12055    if width == 0 || height == 0 || bytes_per_pixel == 0 {
12056        anyhow::bail!("DMA-BUF plane dimensions and bytes per pixel must be non-zero");
12057    }
12058    if !offset.is_multiple_of(4) || !stride.is_multiple_of(4) {
12059        anyhow::bail!("DMA-BUF offset and stride must be 4-byte aligned");
12060    }
12061    let row_bytes = (width as u64)
12062        .checked_mul(bytes_per_pixel as u64)
12063        .ok_or_else(|| anyhow::anyhow!("DMA-BUF row size overflow"))?;
12064    if (stride as u64) < row_bytes {
12065        anyhow::bail!(
12066            "DMA-BUF stride {} is smaller than row size {row_bytes}",
12067            stride
12068        );
12069    }
12070    let span = (height as u64 - 1)
12071        .checked_mul(stride as u64)
12072        .and_then(|value| value.checked_add(row_bytes))
12073        .ok_or_else(|| anyhow::anyhow!("DMA-BUF plane size overflow"))?;
12074    let file = std::fs::File::from(
12075        fd.try_clone()
12076            .map_err(|error| anyhow::anyhow!("duplicate DMA-BUF fd: {error}"))?,
12077    );
12078    let file_size = file
12079        .metadata()
12080        .map_err(|error| anyhow::anyhow!("stat DMA-BUF fd: {error}"))?
12081        .len();
12082    if offset > file_size || span > file_size - offset {
12083        anyhow::bail!("DMA-BUF plane offset/size {offset}+{span} exceeds file size {file_size}");
12084    }
12085    Ok(file_size)
12086}
12087
12088fn is_filterable_color_format(format: wgpu::TextureFormat) -> bool {
12089    matches!(
12090        format,
12091        wgpu::TextureFormat::Rgba8Unorm
12092            | wgpu::TextureFormat::Rgba8UnormSrgb
12093            | wgpu::TextureFormat::Bgra8Unorm
12094            | wgpu::TextureFormat::Bgra8UnormSrgb
12095            | wgpu::TextureFormat::Rgba16Unorm
12096            | wgpu::TextureFormat::Rgba16Float
12097            | wgpu::TextureFormat::Rgba32Float
12098            | wgpu::TextureFormat::Rgb10a2Unorm
12099    )
12100}
12101
12102fn native_format_supported(format: wgpu::TextureFormat, features: wgpu::Features) -> bool {
12103    if !is_filterable_color_format(format) {
12104        return false;
12105    }
12106    match format {
12107        wgpu::TextureFormat::Rgba16Unorm => {
12108            features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
12109        }
12110        wgpu::TextureFormat::Rgba32Float => features.contains(wgpu::Features::FLOAT32_FILTERABLE),
12111        _ => true,
12112    }
12113}
12114
12115fn validate_sampler_descriptor(
12116    device: &wgpu::Device,
12117    sampler: &wgpu::SamplerDescriptor<'_>,
12118) -> anyhow::Result<()> {
12119    if sampler.compare.is_some()
12120        || !matches!(
12121            sampler.mag_filter,
12122            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12123        )
12124        || !matches!(
12125            sampler.min_filter,
12126            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12127        )
12128        || sampler.anisotropy_clamp == 0
12129        || !sampler.lod_min_clamp.is_finite()
12130        || !sampler.lod_max_clamp.is_finite()
12131        || sampler.lod_min_clamp < 0.0
12132        || sampler.lod_max_clamp < sampler.lod_min_clamp
12133    {
12134        anyhow::bail!("native sampler must be non-comparison linear filtering with finite LODs");
12135    }
12136    if (matches!(sampler.address_mode_u, wgpu::AddressMode::ClampToBorder)
12137        || matches!(sampler.address_mode_v, wgpu::AddressMode::ClampToBorder)
12138        || matches!(sampler.address_mode_w, wgpu::AddressMode::ClampToBorder))
12139        && !device
12140            .features()
12141            .contains(wgpu::Features::ADDRESS_MODE_CLAMP_TO_BORDER)
12142    {
12143        anyhow::bail!("native sampler uses unsupported clamp-to-border addressing");
12144    }
12145    Ok(())
12146}
12147
12148fn validate_texture_dimensions(
12149    device: &wgpu::Device,
12150    width: u32,
12151    height: u32,
12152) -> anyhow::Result<()> {
12153    if width == 0
12154        || height == 0
12155        || width > device.limits().max_texture_dimension_2d
12156        || height > device.limits().max_texture_dimension_2d
12157    {
12158        anyhow::bail!("texture dimensions are outside the device limit");
12159    }
12160    Ok(())
12161}
12162
12163fn validate_image_handle(handle: u64) -> anyhow::Result<()> {
12164    if handle == 0 {
12165        anyhow::bail!("image handle 0 is reserved");
12166    }
12167    Ok(())
12168}
12169
12170/// Decoder limits for encoded uploads. `image::Limits::default()` only caps
12171/// allocations at 512 MiB, which a few hundred KB of hostile PNG can reach;
12172/// the dimension cap plus a decode-sized allocation cap keep the transient
12173/// inside the same envelope as the retained image budget.
12174fn decode_limits(max_dimension: u32) -> image::Limits {
12175    let mut limits = image::Limits::default();
12176    limits.max_image_width = Some(max_dimension);
12177    limits.max_image_height = Some(max_dimension);
12178    limits.max_alloc = Some(MAX_IMAGE_DECODE_BYTES);
12179    limits
12180}
12181
12182/// Decode an encoded image to RGBA8, rejecting oversized input *before* the
12183/// decoder allocates. The dimension probe runs against a separate reader
12184/// because `into_dimensions` consumes it.
12185fn decode_image_rgba8(data: &[u8], max_dimension: u32) -> anyhow::Result<(u32, u32, Vec<u8>)> {
12186    let format = image::guess_format(data)?;
12187    let (w, h) = {
12188        let mut probe = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12189        probe.limits(decode_limits(max_dimension));
12190        probe.into_dimensions()?
12191    };
12192    validate_image_decode_budget(w, h, max_dimension)?;
12193    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12194    reader.limits(decode_limits(max_dimension));
12195    let rgba = reader.decode()?.into_rgba8();
12196    Ok((w, h, rgba.into_raw()))
12197}
12198
12199fn validate_image_decode_budget(w: u32, h: u32, max_dimension: u32) -> anyhow::Result<()> {
12200    if w == 0 || h == 0 {
12201        anyhow::bail!("encoded image has zero dimensions");
12202    }
12203    if w > max_dimension || h > max_dimension {
12204        anyhow::bail!("encoded image {w}x{h} exceeds the device texture limit {max_dimension}");
12205    }
12206    let bytes = checked_image_bytes(w, h, 4)?;
12207    if bytes > MAX_IMAGE_DECODE_BYTES {
12208        anyhow::bail!(
12209            "encoded image {w}x{h} needs {bytes} bytes to decode, over the {MAX_IMAGE_DECODE_BYTES} byte limit"
12210        );
12211    }
12212    Ok(())
12213}
12214
12215fn checked_image_bytes(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<u64> {
12216    if w == 0 || h == 0 || bytes_per_pixel == 0 {
12217        anyhow::bail!("image dimensions and bytes per pixel must be non-zero");
12218    }
12219    (w as u64)
12220        .checked_mul(h as u64)
12221        .and_then(|size| size.checked_mul(bytes_per_pixel as u64))
12222        .ok_or_else(|| anyhow::anyhow!("image byte size overflow"))
12223}
12224
12225fn texture_storage_bytes(
12226    format: wgpu::TextureFormat,
12227    width: u32,
12228    height: u32,
12229) -> anyhow::Result<u64> {
12230    if width == 0 || height == 0 {
12231        anyhow::bail!("texture dimensions must be non-zero");
12232    }
12233    let bytes = format.theoretical_memory_footprint(wgpu::Extent3d {
12234        width,
12235        height,
12236        depth_or_array_layers: 1,
12237    });
12238    if bytes == 0 {
12239        anyhow::bail!("texture format has no storage size");
12240    }
12241    Ok(bytes)
12242}
12243
12244fn texture_storage_bytes_with_samples(
12245    format: wgpu::TextureFormat,
12246    width: u32,
12247    height: u32,
12248    sample_count: u32,
12249) -> anyhow::Result<u64> {
12250    texture_storage_bytes(format, width, height)?
12251        .checked_mul(u64::from(sample_count.max(1)))
12252        .ok_or_else(|| anyhow::anyhow!("multisample texture size overflow"))
12253}
12254
12255fn checked_image_bytes_usize(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<usize> {
12256    usize::try_from(checked_image_bytes(w, h, bytes_per_pixel)?)
12257        .map_err(|_| anyhow::anyhow!("image byte size exceeds addressable memory"))
12258}
12259
12260fn clamp_scissor(x: u32, y: u32, w: u32, h: u32, tw: u32, th: u32) -> (u32, u32, u32, u32) {
12261    if w == 0 || h == 0 || tw == 0 || th == 0 {
12262        return (0, 0, 0, 0);
12263    }
12264    let x = x.min(tw - 1);
12265    let y = y.min(th - 1);
12266    let w = w.min(tw - x);
12267    let h = h.min(th - y);
12268    (x, y, w, h)
12269}
12270
12271fn rect_to_scissor(r: repose_core::Rect, width: u32, height: u32) -> (u32, u32, u32, u32) {
12272    if width == 0
12273        || height == 0
12274        || !r.x.is_finite()
12275        || !r.y.is_finite()
12276        || !r.w.is_finite()
12277        || !r.h.is_finite()
12278        || r.w <= 0.0
12279        || r.h <= 0.0
12280    {
12281        return (0, 0, 0, 0);
12282    }
12283    // Inward, for the same reason as `to_scissor`: a clip covers only whole
12284    // pixels inside its rect.
12285    let x0 = r.x.ceil().max(0.0).min(width as f32);
12286    let y0 = r.y.ceil().max(0.0).min(height as f32);
12287    let x1 = (r.x + r.w).floor().max(x0).min(width as f32);
12288    let y1 = (r.y + r.h).floor().max(y0).min(height as f32);
12289    if x1 <= x0 || y1 <= y0 {
12290        return (0, 0, 0, 0);
12291    }
12292    (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
12293}
12294
12295fn rect_to_ndc(r: repose_core::Rect, width: f32, height: f32) -> [f32; 4] {
12296    if width <= 0.0 || height <= 0.0 || !r.x.is_finite() || !r.y.is_finite() {
12297        return [0.0, 0.0, 0.0, 0.0];
12298    }
12299    let x0 = r.x / width * 2.0 - 1.0;
12300    let y0 = 1.0 - r.y / height * 2.0;
12301    let x1 = (r.x + r.w) / width * 2.0 - 1.0;
12302    let y1 = 1.0 - (r.y + r.h) / height * 2.0;
12303    [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]
12304}
12305
12306fn parent_rect_to_layer_rect(
12307    rect: repose_core::Rect,
12308    parent_transform: &Transform,
12309    origin: (f32, f32),
12310) -> Option<repose_core::Rect> {
12311    let inverse = parent_transform.inverse_linear()?;
12312    let tx = parent_transform.translate_x;
12313    let ty = parent_transform.translate_y;
12314    let corners = [
12315        (rect.x, rect.y),
12316        (rect.x + rect.w, rect.y),
12317        (rect.x, rect.y + rect.h),
12318        (rect.x + rect.w, rect.y + rect.h),
12319    ];
12320    let mut min_x = f32::INFINITY;
12321    let mut min_y = f32::INFINITY;
12322    let mut max_x = f32::NEG_INFINITY;
12323    let mut max_y = f32::NEG_INFINITY;
12324    for (x, y) in corners {
12325        let local_x = x - tx;
12326        let local_y = y - ty;
12327        let transformed_x = inverse[0] * local_x + inverse[1] * local_y;
12328        let transformed_y = inverse[2] * local_x + inverse[3] * local_y;
12329        min_x = min_x.min(transformed_x);
12330        min_y = min_y.min(transformed_y);
12331        max_x = max_x.max(transformed_x);
12332        max_y = max_y.max(transformed_y);
12333    }
12334    let result = repose_core::Rect {
12335        x: min_x - origin.0,
12336        y: min_y - origin.1,
12337        w: (max_x - min_x).max(0.0),
12338        h: (max_y - min_y).max(0.0),
12339    };
12340    (result.x.is_finite() && result.y.is_finite() && result.w.is_finite() && result.h.is_finite())
12341        .then_some(result)
12342}
12343
12344fn parent_radii_to_layer_radii(radii: [f32; 4], parent_transform: &Transform) -> Option<[f32; 4]> {
12345    let m = parent_transform.linear();
12346    let sx = (m[0] * m[0] + m[2] * m[2]).sqrt();
12347    let sy = (m[1] * m[1] + m[3] * m[3]).sqrt();
12348    (sx.is_finite() && sy.is_finite() && sx > 1e-6 && sy > 1e-6)
12349        .then(|| [radii[0] / sx, radii[1] / sy, radii[2] / sx, radii[3] / sy])
12350}
12351
12352fn parent_affine_to_layer_affine(
12353    affine: [f32; 6],
12354    parent_transform: &Transform,
12355    origin: (f32, f32),
12356) -> Option<[f32; 6]> {
12357    let inverse = parent_transform.inverse_linear()?;
12358    let tx = affine[4] - parent_transform.translate_x;
12359    let ty = affine[5] - parent_transform.translate_y;
12360    Some([
12361        inverse[0] * affine[0] + inverse[1] * affine[2],
12362        inverse[0] * affine[1] + inverse[1] * affine[3],
12363        inverse[2] * affine[0] + inverse[3] * affine[2],
12364        inverse[2] * affine[1] + inverse[3] * affine[3],
12365        inverse[0] * tx + inverse[1] * ty - origin.0,
12366        inverse[2] * tx + inverse[3] * ty - origin.1,
12367    ])
12368}
12369
12370fn checked_copy_region(
12371    region: repose_core::Rect,
12372    target_width: u32,
12373    target_height: u32,
12374) -> Option<(u32, u32, u32, u32)> {
12375    if target_width == 0
12376        || target_height == 0
12377        || !region.x.is_finite()
12378        || !region.y.is_finite()
12379        || !region.w.is_finite()
12380        || !region.h.is_finite()
12381        || region.w <= 0.0
12382        || region.h <= 0.0
12383    {
12384        return None;
12385    }
12386    let x0 = region.x.floor().max(0.0).min(target_width as f32) as u32;
12387    let y0 = region.y.floor().max(0.0).min(target_height as f32) as u32;
12388    let x1 = (region.x + region.w)
12389        .ceil()
12390        .max(x0 as f32)
12391        .min(target_width as f32) as u32;
12392    let y1 = (region.y + region.h)
12393        .ceil()
12394        .max(y0 as f32)
12395        .min(target_height as f32) as u32;
12396    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
12397}
12398
12399fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
12400    let x0 = a.x.max(b.x);
12401    let y0 = a.y.max(b.y);
12402    let x1 = (a.x + a.w).min(b.x + b.w);
12403    let y1 = (a.y + a.h).min(b.y + b.h);
12404    repose_core::Rect {
12405        x: x0,
12406        y: y0,
12407        w: (x1 - x0).max(0.0),
12408        h: (y1 - y0).max(0.0),
12409    }
12410}
12411
12412#[cfg(test)]
12413mod tests {
12414    use super::*;
12415
12416    #[test]
12417    fn image_source_rect_uses_texel_edges() {
12418        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 3, 2)), 10, 8)
12419            .expect("source should be valid");
12420        assert_eq!(source.uv_min, [0.2, 0.125]);
12421        assert_eq!(source.uv_max, [0.5, 0.375]);
12422        assert_eq!(source.width, 3.0);
12423        assert_eq!(source.height, 2.0);
12424        assert!(resolve_image_source(Some(ImageSourceRect::new(8, 0, 3, 1)), 10, 8).is_none());
12425        assert!(resolve_image_source(Some(ImageSourceRect::new(0, 0, 0, 1)), 10, 8).is_none());
12426    }
12427
12428    #[test]
12429    fn image_fit_stays_inside_selected_source() {
12430        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 4, 2)), 10, 8)
12431            .expect("source should be valid");
12432        let rect = repose_core::Rect {
12433            x: 20.0,
12434            y: 30.0,
12435            w: 8.0,
12436            h: 8.0,
12437        };
12438        let (_, uv) = image_fit_geometry(rect, ImageFit::Cover, ImageAlignment::Center, &source)
12439            .expect("cover geometry should be valid");
12440        assert!(uv[0] >= source.uv_min[0] && uv[2] <= source.uv_max[0]);
12441        assert!(uv[1] >= source.uv_min[1] && uv[3] <= source.uv_max[1]);
12442    }
12443
12444    #[test]
12445    fn ring_alignment_rejects_bad_input() {
12446        assert_eq!(align_up(0, 4).unwrap(), 0);
12447        assert_eq!(align_up(1, 4).unwrap(), 4);
12448        assert!(align_up(1, 3).is_err());
12449        assert!(align_up(u64::MAX, 4).is_err());
12450    }
12451
12452    /// Byte layout of one `MeshUniform`, as the shader sees it.
12453    fn wgsl_type_size(module: &naga::Module, ty: naga::Handle<naga::Type>) -> u32 {
12454        match &module.types[ty].inner {
12455            naga::TypeInner::Scalar(_) | naga::TypeInner::Atomic(_) => 4,
12456            naga::TypeInner::Vector { size, .. } => 4 * *size as u32,
12457            naga::TypeInner::Array {
12458                size: naga::ArraySize::Constant(count),
12459                stride,
12460                ..
12461            } => stride * count.get(),
12462            _ => 0,
12463        }
12464    }
12465
12466    /// `shaders/mesh.wgsl` and `MeshUniform` are two halves of one contract:
12467    /// the shader reads the struct by byte offset, so any member added,
12468    /// dropped, resized or reordered on one side only shows up as scrambled
12469    /// gradient uniforms. Compare member offsets (and the total size) directly.
12470    #[test]
12471    fn mesh_uniform_matches_the_wgsl_member_layout() {
12472        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/mesh.wgsl");
12473        let module = naga::front::wgsl::parse_str(&std::fs::read_to_string(path).expect("read"))
12474            .expect("mesh.wgsl parses");
12475        let wgsl = module
12476            .types
12477            .iter()
12478            .filter(|(_, ty)| ty.name.as_deref() == Some("MeshUniform"))
12479            .find_map(|(_, ty)| match &ty.inner {
12480                naga::TypeInner::Struct { members, .. } => Some(members.as_slice()),
12481                _ => None,
12482            })
12483            .expect("mesh.wgsl declares MeshUniform");
12484        let rust = [
12485            ("m0", std::mem::offset_of!(MeshUniform, m0)),
12486            ("m1", std::mem::offset_of!(MeshUniform, m1)),
12487            ("paint", std::mem::offset_of!(MeshUniform, paint)),
12488            ("color0", std::mem::offset_of!(MeshUniform, color0)),
12489            ("color1", std::mem::offset_of!(MeshUniform, color1)),
12490            ("grad_start", std::mem::offset_of!(MeshUniform, grad_start)),
12491            ("grad_end", std::mem::offset_of!(MeshUniform, grad_end)),
12492            (
12493                "stop_colors",
12494                std::mem::offset_of!(MeshUniform, stop_colors),
12495            ),
12496            (
12497                "stop_offsets",
12498                std::mem::offset_of!(MeshUniform, stop_offsets),
12499            ),
12500        ];
12501        assert_eq!(wgsl.len(), rust.len(), "MeshUniform member count");
12502        for (member, (name, offset)) in wgsl.iter().zip(rust) {
12503            assert_eq!(member.name.as_deref(), Some(name), "member order");
12504            assert_eq!(member.offset as usize, offset, "offset of {name}");
12505        }
12506        let last = wgsl.last().expect("non-empty");
12507        let wgsl_size = last.offset as usize + wgsl_type_size(&module, last.ty) as usize;
12508        assert_eq!(wgsl_size, std::mem::size_of::<MeshUniform>());
12509        assert_eq!(wgsl_size, MESH_UNIFORM_BYTES);
12510    }
12511
12512    /// The stop table rides in the same uniform as the two-point ramps, so the
12513    /// packing invariants the shader relies on (count, clamped non-decreasing
12514    /// offsets, colours left in linear space) are checked where they are built.
12515    #[test]
12516    fn gradient_stops_pack_clamped_and_monotonic() {
12517        let stops = GradientStops::from_slice(&[
12518            repose_core::GradientStop {
12519                offset: 0.25,
12520                color: repose_core::Color::from_rgba(255, 0, 0, 255),
12521            },
12522            repose_core::GradientStop {
12523                offset: f32::NAN,
12524                color: repose_core::Color::from_rgba(0, 255, 0, 255),
12525            },
12526            repose_core::GradientStop {
12527                offset: -2.0,
12528                color: repose_core::Color::from_rgba(0, 0, 255, 255),
12529            },
12530            repose_core::GradientStop {
12531                offset: 9.0,
12532                color: repose_core::Color::from_rgba(255, 255, 255, 255),
12533            },
12534        ]);
12535        let (colors, offsets, count) = pack_gradient_stops(&stops);
12536        assert_eq!(count, 4);
12537        assert_eq!(offsets, [[0.25, 0.25, 0.25, 1.0], [0.0, 0.0, 0.0, 0.0]]);
12538        assert_eq!(colors[0], [1.0, 0.0, 0.0, 1.0]);
12539        assert_eq!(colors[1], [0.0, 1.0, 0.0, 1.0]);
12540        assert_eq!(colors[2], [0.0, 0.0, 1.0, 1.0]);
12541        assert_eq!(colors[3], [1.0, 1.0, 1.0, 1.0]);
12542        assert_eq!(
12543            pack_gradient_stops(&GradientStops::default()).2,
12544            0,
12545            "an empty table paints nothing"
12546        );
12547    }
12548
12549    /// A repack moves every surviving glyph, so eviction must never touch one
12550    /// the in-flight frame already holds UVs for, and must never empty the map.
12551    /// Glyph 1 is the coldest overall but in flight, so it has to survive even
12552    /// though glyphs 3 and 4 are warmer and evictable.
12553    #[test]
12554    fn atlas_eviction_spares_the_frame_in_flight() {
12555        fn key(glyph_id: u32) -> repose_text::CacheKey {
12556            repose_text::CacheKey {
12557                font_id: 1,
12558                glyph_id,
12559                font_size_bits: 16.0f32.to_bits(),
12560                variation: None,
12561            }
12562        }
12563        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12564        for (glyph_id, last_touch, last_used_frame) in
12565            [(1u32, 10u64, 9u64), (2, 20, 7), (3, 30, 7), (4, 40, 7)]
12566        {
12567            map.insert(
12568                key(glyph_id),
12569                GlyphInfo {
12570                    u0: 0.0,
12571                    v0: 0.0,
12572                    u1: 0.1,
12573                    v1: 0.1,
12574                    w: 10.0,
12575                    h: 10.0,
12576                    last_touch,
12577                    last_used_frame,
12578                },
12579            );
12580        }
12581        let mut used_area = 400;
12582        assert!(evict_atlas_lru(&mut map, &mut used_area, 9, 250));
12583        assert_eq!(used_area, 200);
12584        assert!(map.contains_key(&key(1)), "in-flight glyph was evicted");
12585        assert!(!map.contains_key(&key(2)) && !map.contains_key(&key(3)));
12586        assert!(map.contains_key(&key(4)));
12587    }
12588
12589    #[test]
12590    fn atlas_eviction_refuses_to_empty_the_atlas() {
12591        let only = repose_text::CacheKey {
12592            font_id: 1,
12593            glyph_id: 1,
12594            font_size_bits: 16.0f32.to_bits(),
12595            variation: None,
12596        };
12597        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12598        map.insert(
12599            only,
12600            GlyphInfo {
12601                u0: 0.0,
12602                v0: 0.0,
12603                u1: 0.1,
12604                v1: 0.1,
12605                w: 10.0,
12606                h: 10.0,
12607                last_touch: 1,
12608                last_used_frame: 1,
12609            },
12610        );
12611        let mut used_area = 100;
12612        assert!(!evict_atlas_lru(&mut map, &mut used_area, 2, 0));
12613        assert_eq!(map.len(), 1);
12614        assert_eq!(used_area, 100);
12615    }
12616
12617    #[test]
12618    fn empty_and_negative_rects_are_suppressed() {
12619        assert_eq!(
12620            rect_to_scissor(
12621                repose_core::Rect {
12622                    x: 0.0,
12623                    y: 0.0,
12624                    w: 0.0,
12625                    h: 4.0,
12626                },
12627                16,
12628                16,
12629            ),
12630            (0, 0, 0, 0)
12631        );
12632        assert_eq!(
12633            rect_to_scissor(
12634                repose_core::Rect {
12635                    x: 0.0,
12636                    y: 0.0,
12637                    w: -1.0,
12638                    h: 4.0,
12639                },
12640                16,
12641                16,
12642            ),
12643            (0, 0, 0, 0)
12644        );
12645    }
12646
12647    #[test]
12648    fn copy_region_is_checked() {
12649        assert_eq!(
12650            checked_copy_region(
12651                repose_core::Rect {
12652                    x: 2.25,
12653                    y: 3.5,
12654                    w: 4.0,
12655                    h: 5.0,
12656                },
12657                16,
12658                16,
12659            ),
12660            Some((2, 3, 5, 6))
12661        );
12662        assert_eq!(
12663            checked_copy_region(
12664                repose_core::Rect {
12665                    x: 20.0,
12666                    y: 0.0,
12667                    w: 2.0,
12668                    h: 2.0,
12669                },
12670                16,
12671                16,
12672            ),
12673            None
12674        );
12675    }
12676
12677    #[test]
12678    fn decode_budget_rejects_oversized_encoded_images() {
12679        assert!(validate_image_decode_budget(64, 64, 8192).is_ok());
12680        assert!(validate_image_decode_budget(0, 64, 8192).is_err());
12681        assert!(validate_image_decode_budget(64, 0, 8192).is_err());
12682        assert!(validate_image_decode_budget(8193, 1, 8192).is_err());
12683        assert!(validate_image_decode_budget(1, 8193, 8192).is_err());
12684        // Fits the device, but not the decode allocation budget.
12685        assert!(validate_image_decode_budget(8192, 4096, 8192).is_err());
12686    }
12687
12688    #[test]
12689    fn only_wgpu_supported_sample_counts_are_accepted() {
12690        for samples in [1, 2, 4, 8, 16] {
12691            assert!(validate_sample_count("msaa_samples", samples).is_ok());
12692        }
12693        for samples in [0, 3, 5, 7, 9, 17, 32, u32::MAX] {
12694            assert!(
12695                validate_sample_count("msaa_samples", samples).is_err(),
12696                "{samples} must be rejected"
12697            );
12698        }
12699    }
12700
12701    #[test]
12702    fn p010_uses_ten_bit_sample_range() {
12703        let raw = make_yuv_transform_raw(ColorInfo::default(), PixelFormat::P010);
12704        let expected = 65535.0 / (64.0 * 1020.0);
12705        assert!((raw.b[3] - expected).abs() < 1e-6);
12706        assert_eq!(canonical_fourcc(PixelFormat::P010), P010_FOURCC);
12707        assert_eq!(canonical_fourcc(PixelFormat::Nv12), NV12_FOURCC);
12708        assert_eq!(
12709            make_yuv_transform_raw(
12710                ColorInfo {
12711                    transfer: repose_core::color::Transfer::Pq,
12712                    ..ColorInfo::default()
12713                },
12714                PixelFormat::Nv12,
12715            )
12716            .transfer[0],
12717            3.0
12718        );
12719        assert_eq!(
12720            make_yuv_transform_raw(
12721                ColorInfo {
12722                    transfer: repose_core::color::Transfer::Hlg,
12723                    ..ColorInfo::default()
12724                },
12725                PixelFormat::Nv12,
12726            )
12727            .transfer[0],
12728            4.0
12729        );
12730    }
12731}