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
33/// Stride between per-draw uniform slots. Only the slot start must satisfy the
34/// device's `min_uniform_buffer_offset_alignment`, so round to the next power
35/// of two above that: always a legal offset, and a power of two so
36/// [`align_up`] can size the buffer. Rounding to the bare minimum (272 -> 288
37/// at the common 32-byte alignment) produced a non-power-of-two stride, which
38/// made `align_up` reject the capacity and collapse the buffer to one slot, so
39/// every mesh after the first in a frame was silently dropped.
40fn mesh_uniform_stride(size: u64, base: u64) -> u64 {
41    let rounded = size.saturating_add(base.saturating_sub(1)) / base * base;
42    rounded.next_power_of_two().max(base)
43}
44
45mod commands;
46pub use commands::RenderCommandFailure;
47pub use commands::RenderCommandReport;
48pub use commands::apply_render_commands;
49
50pub mod offscreen;
51
52mod callback;
53pub use callback::{
54    Callback, CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback,
55};
56
57mod depth_composite;
58pub use depth_composite::{BlitRenderPass, DepthComposite};
59
60#[derive(Clone)]
61struct UploadRing {
62    buf: wgpu::Buffer,
63    cap: u64,
64    head: u64,
65    usage: wgpu::BufferUsages,
66}
67
68impl UploadRing {
69    fn new(device: &wgpu::Device, label: &str, cap: u64, usage: wgpu::BufferUsages) -> Self {
70        let max = device.limits().max_buffer_size;
71        let cap = cap.max(4).min(max).min(MAX_UPLOAD_RING_BYTES).max(4);
72        let buf = device.create_buffer(&wgpu::BufferDescriptor {
73            label: Some(label),
74            size: cap,
75            usage,
76            mapped_at_creation: false,
77        });
78        Self {
79            buf,
80            cap,
81            head: 0,
82            usage,
83        }
84    }
85
86    fn reset(&mut self) {
87        self.head = 0;
88    }
89
90    fn grow_to_fit(
91        &mut self,
92        device: &wgpu::Device,
93        encoder: &mut wgpu::CommandEncoder,
94        needed: u64,
95    ) -> anyhow::Result<()> {
96        if needed == 0 {
97            return Ok(());
98        }
99        let start = align_up(self.head, 4)?;
100        let end = start
101            .checked_add(needed)
102            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
103        if end <= self.cap {
104            return Ok(());
105        }
106        let required = align_up(end, 4)?;
107        let doubled = self.cap.checked_mul(2).unwrap_or(0);
108        let new_cap = required.max(doubled).max(256);
109        let new_cap = align_up(new_cap, 4)?;
110        if new_cap > device.limits().max_buffer_size || new_cap > MAX_UPLOAD_RING_BYTES {
111            anyhow::bail!("upload ring exceeds resource budget");
112        }
113        if !self.usage.contains(wgpu::BufferUsages::COPY_SRC) {
114            anyhow::bail!("upload ring growth requires COPY_SRC");
115        }
116        let old = self.buf.clone();
117        self.buf = device.create_buffer(&wgpu::BufferDescriptor {
118            label: Some("upload ring (grown)"),
119            size: new_cap,
120            usage: self.usage,
121            mapped_at_creation: false,
122        });
123        self.cap = new_cap;
124        if self.head > 0 {
125            let copy_size = align_up(self.head, 4)?;
126            if copy_size > old.size() {
127                anyhow::bail!("upload ring old data exceeds old buffer");
128            }
129            encoder.copy_buffer_to_buffer(&old, 0, &self.buf, 0, copy_size);
130        }
131        Ok(())
132    }
133
134    fn alloc_write(&mut self, queue: &wgpu::Queue, bytes: &[u8]) -> anyhow::Result<u64> {
135        let len = u64::try_from(bytes.len())
136            .map_err(|_| anyhow::anyhow!("upload byte length exceeds u64"))?;
137        let start = align_up(self.head, 4)?;
138        let end = start
139            .checked_add(len)
140            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
141        if end > self.cap {
142            anyhow::bail!(
143                "upload ring overflow: start={start} len={len} cap={}",
144                self.cap
145            );
146        }
147        queue.write_buffer(&self.buf, start, bytes);
148        self.head = end;
149        Ok(start)
150    }
151}
152
153struct InstancedPipe<I: bytemuck::Pod> {
154    ring: UploadRing,
155    _marker: std::marker::PhantomData<I>,
156}
157
158impl<I: bytemuck::Pod> InstancedPipe<I> {
159    fn new(ring: UploadRing) -> Self {
160        Self {
161            ring,
162            _marker: std::marker::PhantomData,
163        }
164    }
165
166    fn upload(
167        &mut self,
168        device: &wgpu::Device,
169        queue: &wgpu::Queue,
170        encoder: &mut wgpu::CommandEncoder,
171        data: &[I],
172    ) -> Option<(u64, u32)> {
173        if data.is_empty() {
174            return None;
175        }
176        let count = u32::try_from(data.len()).ok()?;
177        let bytes = bytemuck::cast_slice(data);
178        let byte_len = u64::try_from(bytes.len()).ok()?;
179        if byte_len == 0 || byte_len > device.limits().max_buffer_size {
180            return None;
181        }
182        self.ring
183            .grow_to_fit(device, encoder, byte_len)
184            .map_err(|error| log::error!("{error:#}"))
185            .ok()?;
186        let off = self
187            .ring
188            .alloc_write(queue, bytes)
189            .map_err(|error| log::error!("{error:#}"))
190            .ok()?;
191        Some((off, count))
192    }
193
194    fn reset(&mut self) {
195        self.ring.reset();
196    }
197}
198
199#[repr(C)]
200#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
201struct Globals {
202    ndc_to_px: [f32; 2],
203    _pad: [f32; 2],
204}
205
206fn make_globals(target_w: f32, target_h: f32) -> Globals {
207    Globals {
208        ndc_to_px: [target_w * 0.5, target_h * 0.5],
209        _pad: [0.0, 0.0],
210    }
211}
212
213pub struct WgpuSceneRenderer {
214    pub device: wgpu::Device,
215    pub queue: wgpu::Queue,
216    pub output_format: wgpu::TextureFormat,
217    /// Texture format of the surface the multisampled attachment resolves into.
218    surface_format: wgpu::TextureFormat,
219    pub output_width: u32,
220    pub output_height: u32,
221    /// Pixels per point (DPI scale) for `ScreenDescriptor` / `PaintCallbackInfo`.
222    pub pixels_per_point: f32,
223
224    // Render pipelines. Two sets: one for the MSAA surface pass, one for
225    // graphics-layer render-to-texture passes (sample_count = 1).
226    surface_pipes: Pipelines,
227    working_space_pipes: Pipelines,
228    layer_pipes: Pipelines,
229    working_space_layer_pipes: Pipelines,
230
231    // Instanced draw rings
232    rects: InstancedPipe<RectInstance>,
233    borders: InstancedPipe<BorderInstance>,
234    ellipses: InstancedPipe<EllipseInstance>,
235    ellipse_borders: InstancedPipe<EllipseBorderInstance>,
236    arcs: InstancedPipe<ArcInstance>,
237    glyph_mask: InstancedPipe<GlyphInstance>,
238    glyph_color: InstancedPipe<GlyphInstance>,
239
240    // Image bind layouts and shared sampler
241    image_bind_layout_rgba: wgpu::BindGroupLayout,
242    image_bind_layout_nv12: wgpu::BindGroupLayout,
243    image_sampler: wgpu::Sampler,
244    image_sampler_nearest: wgpu::Sampler,
245    image_sampler_repeat: wgpu::Sampler,
246    image_sampler_repeat_nearest: wgpu::Sampler,
247    layer_sampler: wgpu::Sampler,
248    layer_sampler_linear: wgpu::Sampler,
249
250    // Blur composite ring (for graphics-layer drop shadows)
251    blur_ring: UploadRing,
252
253    text_bind_layout: wgpu::BindGroupLayout,
254
255    // Stencil clip ring
256    clip_ring: UploadRing,
257
258    // Projective layer-composite ring (one ProjectiveInstance per flattened
259    // perspective subtree)
260    projective_ring: UploadRing,
261
262    // Backdrop-blend composite ring (one BlendInstance per isolated blend)
263    blend_ring: UploadRing,
264
265    // Tessellated vector glyph pipeline (always enabled)
266    slug_enabled: bool,
267    slug_ring: UploadRing,
268    slug_cache: slug::GlyphSlugCache,
269    slug_draw_cache: HashMap<SlugDrawKey, SlugDrawCacheEntry>,
270    slug_draw_cache_bytes_total: u64,
271
272    // Instanced NV12 ring
273    nv12: InstancedPipe<Nv12Instance>,
274
275    mesh_cache: HashMap<MeshCacheKey, MeshCacheEntry>,
276    mesh_cache_bytes_total: u64,
277    mesh_uniform_buf: wgpu::Buffer,
278    mesh_bind: wgpu::BindGroup,
279    mesh_uniform_head: u64,
280    mesh_uniform_alignment: u64,
281    mesh_uniform_cap: u64,
282
283    /// Translator-owned flatten layer ids used by the previous frame;
284    /// drained from the layer pool at the start of each translation (they
285    /// are single-frame by construction).
286    flatten_layer_ids: Vec<u32>,
287
288    /// Layer ids handed out to separable-blur scratches, keyed by
289    /// `(source layer, slot)` so they survive the stale-layer sweep and stay
290    /// bound across frames. `blur_scratch_next` is the handout counter for
291    /// those ids; it only ever grows, so a freed slot's id is never reissued.
292    blur_scratch_ids: HashMap<(u32, u32), u32>,
293    blur_scratch_next: u32,
294
295    /// Backdrop snapshots keyed by isolated-blend layer id. Filled during
296    /// translation (texture allocated) and populated by a texture copy at
297    /// execution time, before the blend composite draws.
298    blend_snapshots: std::collections::HashMap<u32, BlendSnapshot>,
299    blend_snapshot_pool: HashMap<BlendSnapshotKey, BlendSnapshot>,
300    /// (blend layer id, parent target) copies to run before the pass that
301    /// composites the blend. Executed between passes: copies the current
302    /// target region into the snapshot texture.
303    blend_copies: Vec<BlendCopy>,
304
305    msaa_samples: u32,
306    working_space_msaa_samples: u32,
307
308    // Depth-stencil target
309    depth_stencil_tex: wgpu::Texture,
310    depth_stencil_view: wgpu::TextureView,
311
312    // Optional MSAA color target
313    msaa_tex: Option<wgpu::Texture>,
314    msaa_view: Option<wgpu::TextureView>,
315    ws_msaa_tex: Option<wgpu::Texture>,
316    ws_msaa_view: Option<wgpu::TextureView>,
317    surface_resolve_tex: Option<wgpu::Texture>,
318    surface_resolve_view: Option<wgpu::TextureView>,
319    surface_resolve_bytes: u64,
320    msaa_bytes: u64,
321    ws_msaa_bytes: u64,
322    depth_stencil_bytes: u64,
323    working_space_bytes: u64,
324    gpu_budget_bytes: u64,
325
326    globals_buf: wgpu::Buffer,
327    globals_bind: wgpu::BindGroup,
328    globals_staging: wgpu::Buffer,
329    globals_staging_cap: u64,
330
331    // Glyph atlas
332    atlas_mask: AtlasA8,
333    atlas_color: AtlasRGBA,
334    atlas_mask_bind: wgpu::BindGroup,
335    atlas_color_bind: wgpu::BindGroup,
336    atlas_generation: u64,
337    atlas_mask_pending: AtlasPending,
338    atlas_color_pending: AtlasPending,
339    atlas_last_compact_frame: u64,
340    atlas_mask_failures: HashSet<repose_text::CacheKey>,
341    atlas_color_failures: HashSet<repose_text::CacheKey>,
342    /// Glyphs that rasterized fine but had no room. Skipped until the next
343    /// repack so a full atlas does not re-rasterize every frame.
344    atlas_mask_deferred: HashSet<repose_text::CacheKey>,
345    atlas_color_deferred: HashSet<repose_text::CacheKey>,
346
347    glyph_outline_cache: HashMap<repose_text::CacheKey, GlyphOutlineEntry>,
348    legacy_shaped_text_cache: HashMap<TextShapeKey, TextShapeCacheEntry>,
349
350    // Image management
351    next_image_handle: u64,
352    images: HashMap<u64, ImageTex>,
353    retained: HashMap<u64, RetainedImage>,
354    retained_bytes_total: u64,
355
356    // A8 coverage-tile management (host-rasterized masks composited tinted;
357    // no retained CPU copies - tiles are immutable and re-registered).
358    next_coverage_handle: u64,
359    coverages: HashMap<u64, CoverageTex>,
360
361    // Eviction stats
362    frame_index: u64,
363    cache_touch_clock: u64,
364    image_bytes_total: u64,
365    image_evict_after_frames: u64,
366    image_budget_bytes: u64,
367
368    // Graphics layer pool. Maps `SceneNode::BeginLayer::layer_id` to a
369    // cached offscreen render target.
370    layer_pool: HashMap<u32, LayerTarget>,
371    producer_layer_ids: HashSet<u32>,
372    layer_bytes_total: u64,
373    transient_layer_bytes_total: u64,
374
375    // Linear working-space mode (default off -> fast playback path).
376    // When enabled, the scene is rendered into an Rgba16Float intermediate
377    // texture, then a final full-screen pass applies the display OETF.
378    working_space: bool,
379    ws_tex: Option<wgpu::Texture>,
380    ws_view: Option<wgpu::TextureView>,
381    ws_bind: Option<wgpu::BindGroup>,
382    display_pipeline: Option<wgpu::RenderPipeline>,
383    display_layout: Option<wgpu::BindGroupLayout>,
384
385    pub callback_resources: CallbackResources,
386    callback_scoped_resources: HashMap<CallbackScopeKey, CallbackResources>,
387    callback_scope_uses: HashMap<CallbackScopeKey, CallbackScopeUse>,
388    callback_scope_payloads: HashMap<CallbackScopeKey, Weak<Callback>>,
389    callback_scope_clock: u64,
390    blend_snapshot_bytes_total: u64,
391    frame_active: bool,
392    last_render_error: Option<String>,
393}
394
395pub struct WgpuSurfaceBackend {
396    #[cfg(feature = "winit-surface")]
397    instance: Option<wgpu::Instance>,
398    #[cfg(feature = "winit-surface")]
399    window: Option<Arc<winit::window::Window>>,
400    pub surface: Option<wgpu::Surface<'static>>,
401    pub surface_config: Option<wgpu::SurfaceConfiguration>,
402    #[cfg_attr(not(feature = "winit-surface"), allow(dead_code))]
403    pending_reconfigure: bool,
404    pub renderer: WgpuSceneRenderer,
405    device_lost: Arc<Mutex<Option<(wgpu::DeviceLostReason, String)>>>,
406}
407
408impl std::ops::Deref for WgpuSurfaceBackend {
409    type Target = WgpuSceneRenderer;
410    fn deref(&self) -> &Self::Target {
411        &self.renderer
412    }
413}
414impl std::ops::DerefMut for WgpuSurfaceBackend {
415    fn deref_mut(&mut self) -> &mut Self::Target {
416        &mut self.renderer
417    }
418}
419
420#[cfg(feature = "winit-surface")]
421pub type WgpuBackend = WgpuSurfaceBackend;
422
423/// Largest `(width, height)` not exceeding `max` on either axis while keeping
424/// the requested aspect ratio.
425///
426/// Clamping the two axes independently rescales them by different factors, so
427/// "CSS pixels to viewport pixels" stops being a single number: the scene is
428/// projected against one shape while input is resolved against another, which
429/// shows up as a touch offset that grows with distance from the origin. One
430/// uniform factor keeps layout, hit testing and the framebuffer on the same
431/// scale, leaving a uniform loss of resolution.
432pub fn clamp_output_size(width: u32, height: u32, max: u32) -> (u32, u32) {
433    if width == 0 || height == 0 {
434        return (0, 0);
435    }
436    let max = max.max(1);
437    if width <= max && height <= max {
438        return (width, height);
439    }
440    let scale = max as f64 / width.max(height) as f64;
441    let clamped = |v: u32| ((v as f64 * scale).round() as u32).clamp(1, max);
442    (clamped(width), clamped(height))
443}
444
445/// Compose/Skia radius -> gaussian sigma (the `1 / sqrt(3)` the high-quality
446/// software mask blur applies).
447fn blur_sigma(radius: f32) -> f32 {
448    (radius.max(0.0) * 0.577_350_3 + 0.5).max(0.0)
449}
450
451/// Padding needed on each side of a layer for a separable blur to fit its
452/// 3-sigma support without clipping the kernel.
453fn blur_pad(radius: f32) -> f32 {
454    (blur_sigma(radius) * 3.0).ceil().max(0.0)
455}
456
457/// First translator-owned blur-scratch layer id. Producer ids start at 1 per
458/// scene and flatten owns `FLATTEN_ID_BASE`, so this range never collides.
459const BLUR_SCRATCH_ID_BASE: u32 = 0xE000_0000;
460
461/// Scratch slot for the `Modifier::blur` half of a layer's content.
462const BLUR_SLOT_CONTENT: u32 = 0;
463/// Scratch slot for the shadow's half-plane kernel, so a layer can carry a
464/// content blur and a shadow without either pass clobbering the other's
465/// scratch.
466const BLUR_SLOT_SHADOW: u32 = 1;
467
468impl Drop for WgpuSceneRenderer {
469    fn drop(&mut self) {
470        let _ = self.device.poll(wgpu::PollType::Poll);
471        #[cfg(not(target_arch = "wasm32"))]
472        {
473            let _ = self.device.poll(wgpu::PollType::Wait {
474                submission_index: None,
475                timeout: Some(web_time::Duration::from_millis(100)),
476            });
477        }
478    }
479}
480
481#[derive(Clone)]
482struct LayerTarget {
483    texture: wgpu::Texture,
484    view: wgpu::TextureView,
485    bind: wgpu::BindGroup,
486    bind_linear: wgpu::BindGroup,
487    depth_stencil_view: wgpu::TextureView,
488    width: u32,
489    height: u32,
490    format: wgpu::TextureFormat,
491    bytes: u64,
492    rect_px: (f32, f32, f32, f32),
493    transient: bool,
494}
495
496/// Backdrop snapshot for one isolated blend: a copy of the current target
497/// region taken before the source layer is composited, sampled as the
498/// "backdrop" input of the blend shader.
499#[derive(Clone, Copy, PartialEq, Eq, Hash)]
500struct BlendSnapshotKey {
501    format: wgpu::TextureFormat,
502    width: u32,
503    height: u32,
504}
505
506#[derive(Clone)]
507struct BlendSnapshot {
508    key: BlendSnapshotKey,
509    texture: wgpu::Texture,
510    bind: wgpu::BindGroup,
511    bytes: u64,
512}
513
514struct BlendCopy {
515    pass_id: u64,
516    blend_id: u32,
517    target: PassTarget,
518    region: repose_core::Rect,
519}
520
521#[derive(Clone)]
522enum ActiveClip {
523    Rect {
524        off: u64,
525        cnt: u32,
526        rect: repose_core::Rect,
527        radii: [f32; 4],
528        /// Scissor the matching push drew its stencil pass with. The pop must
529        /// reuse it: a different (wider) bound decrements stencil the push
530        /// never incremented, leaving those pixels a level below the base so
531        /// every later `Equal` draw drops them.
532        scissor: (u32, u32, u32, u32),
533        difference: bool,
534        applied: bool,
535        blocked: bool,
536    },
537    Vector {
538        mesh: Option<MeshGpu>,
539        source: Arc<repose_core::VectorMeshData>,
540        uoff: u64,
541        affine: [f32; 6],
542        scissor: (u32, u32, u32, u32),
543        difference: bool,
544        applied: bool,
545        blocked: bool,
546    },
547}
548
549impl ActiveClip {
550    fn difference(&self) -> bool {
551        match self {
552            Self::Rect { difference, .. } | Self::Vector { difference, .. } => *difference,
553        }
554    }
555
556    fn blocked(&self) -> bool {
557        match self {
558            Self::Rect { blocked, .. } | Self::Vector { blocked, .. } => *blocked,
559        }
560    }
561}
562
563/// Identifies which render target a `Pass` draws into.
564#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
565enum PassTarget {
566    Surface,
567    Layer(u32),
568}
569
570#[derive(Clone, Debug, PartialEq, Eq, Hash)]
571struct CallbackIdentity {
572    type_id: TypeId,
573    /// The callback's own key, kept whole: two colliding digests must not
574    /// share a resource bucket.
575    key: Option<Arc<str>>,
576    reusable: bool,
577}
578
579/// Identifies one callback's `CallbackResources` bucket. Deliberately
580/// excludes the target's size and scale: `prepare` runs every frame with
581/// the current [`ScreenDescriptor`], so any size-dependent resource
582/// belongs in the callback's own `ensure`. Keying the bucket by size
583/// would drop every callback's GPU state on a window resize - atlas
584/// contents, pipeline caches - leaving nothing to rebuild from.
585///
586/// A bucket still changes identity with the target, format or sample
587/// count, and is evicted once [`MAX_CALLBACK_SCOPES`] is exceeded, so
588/// anything loaded once in `prepare` has to be rebuildable from
589/// `prepare` alone.
590#[derive(Clone, Debug, PartialEq, Eq, Hash)]
591struct CallbackScopeKey {
592    callback: CallbackIdentity,
593    target: PassTarget,
594    target_format: wgpu::TextureFormat,
595    sample_count: u32,
596}
597
598#[derive(Clone, Copy)]
599struct CallbackScopeUse {
600    tick: u64,
601    frame: u64,
602}
603
604fn callback_identity(callback: &Callback, fallback: usize) -> CallbackIdentity {
605    match callback.0.resource_key() {
606        Some(key) => CallbackIdentity {
607            type_id: callback.0.resource_type_id(),
608            key: Some(Arc::from(key)),
609            reusable: true,
610        },
611        None => CallbackIdentity {
612            type_id: TypeId::of::<Callback>(),
613            key: Some(Arc::from(fallback.to_string())),
614            reusable: false,
615        },
616    }
617}
618
619fn callback_scope_key(
620    callback: &Callback,
621    fallback: usize,
622    target: PassTarget,
623    descriptor: &ScreenDescriptor,
624) -> CallbackScopeKey {
625    CallbackScopeKey {
626        callback: callback_identity(callback, fallback),
627        target,
628        target_format: descriptor.target_format,
629        sample_count: descriptor.sample_count,
630    }
631}
632
633/// Blend modes the GPU can express with fixed-function blending (plus
634/// [`Alpha`](repose_core::BlendMode::Alpha), the premultiplied default). Every
635/// other mode isolates into a graphics layer and composites with the
636/// `blend_layer` shader.
637const FIXED_FUNCTION_BLENDS: [repose_core::BlendMode; 5] = [
638    repose_core::BlendMode::Add,
639    repose_core::BlendMode::Multiply,
640    repose_core::BlendMode::Screen,
641    repose_core::BlendMode::Darken,
642    repose_core::BlendMode::Lighten,
643];
644
645/// Hardware blend state for a [`FIXED_FUNCTION_BLENDS`] mode, or `None` for
646/// `Alpha` and every mode that needs an isolated backdrop pass. Shared by the
647/// mesh and image pipelines so both composite identically.
648fn fixed_function_blend(mode: repose_core::BlendMode) -> Option<wgpu::BlendState> {
649    let premult_alpha = wgpu::BlendComponent::OVER;
650    let additive = wgpu::BlendComponent {
651        src_factor: wgpu::BlendFactor::One,
652        dst_factor: wgpu::BlendFactor::One,
653        operation: wgpu::BlendOperation::Add,
654    };
655    Some(match mode {
656        repose_core::BlendMode::Add => wgpu::BlendState {
657            color: additive,
658            alpha: additive,
659        },
660        repose_core::BlendMode::Multiply => wgpu::BlendState {
661            color: wgpu::BlendComponent {
662                src_factor: wgpu::BlendFactor::Dst,
663                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
664                operation: wgpu::BlendOperation::Add,
665            },
666            alpha: premult_alpha,
667        },
668        repose_core::BlendMode::Screen => wgpu::BlendState {
669            color: wgpu::BlendComponent {
670                src_factor: wgpu::BlendFactor::One,
671                dst_factor: wgpu::BlendFactor::OneMinusSrc,
672                operation: wgpu::BlendOperation::Add,
673            },
674            alpha: premult_alpha,
675        },
676        repose_core::BlendMode::Darken => wgpu::BlendState {
677            color: wgpu::BlendComponent {
678                src_factor: wgpu::BlendFactor::One,
679                dst_factor: wgpu::BlendFactor::One,
680                operation: wgpu::BlendOperation::Min,
681            },
682            alpha: premult_alpha,
683        },
684        repose_core::BlendMode::Lighten => wgpu::BlendState {
685            color: wgpu::BlendComponent {
686                src_factor: wgpu::BlendFactor::One,
687                dst_factor: wgpu::BlendFactor::One,
688                operation: wgpu::BlendOperation::Max,
689            },
690            alpha: premult_alpha,
691        },
692        _ => return None,
693    })
694}
695
696/// A bundle of render pipelines for a single sample-count target. Created
697/// twice: once with `sample_count = msaa_samples` for the surface pass, and
698/// once with `sample_count = 1` for graphics-layer render-to-texture passes
699/// (where MSAA is wasted).
700#[derive(Clone)]
701struct Pipelines {
702    rects: wgpu::RenderPipeline,
703    borders: wgpu::RenderPipeline,
704    ellipses: wgpu::RenderPipeline,
705    ellipse_borders: wgpu::RenderPipeline,
706    arcs: wgpu::RenderPipeline,
707    text_mask: wgpu::RenderPipeline,
708    text_color: wgpu::RenderPipeline,
709    image_rgba: wgpu::RenderPipeline,
710    /// Fixed-function blend variants of `image_rgba`, indexed like
711    /// `FIXED_FUNCTION_BLENDS`. `SceneNode::ImageBlended` with a separable
712    /// mode picks one here; the rest go through isolation.
713    image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
714    /// Tinted A8 coverage composite (`coverage.wgsl`): same vertex
715    /// attributes and bind groups as the text/color path, sampling a
716    /// single-channel tile registered with `register_coverage_a8`.
717    coverage: wgpu::RenderPipeline,
718    image_nv12: wgpu::RenderPipeline,
719    /// Fixed-function blend variants of `image_nv12`, indexed like
720    /// `FIXED_FUNCTION_BLENDS`.
721    image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
722    blur: wgpu::RenderPipeline,
723    blur_content: wgpu::RenderPipeline,
724    clip_bin: wgpu::RenderPipeline,
725    clip_dec: wgpu::RenderPipeline,
726    slug: Option<wgpu::RenderPipeline>,
727    /// Tessellated vector mesh (fill/stroke). Uses an `Equal` stencil compare
728    /// so world content is correctly masked to the active `PushVectorClip`
729    /// shape (outside any clip the stencil is 0 == ref 0, so it draws).
730    mesh: wgpu::RenderPipeline,
731    /// Screen-space overlay meshes: `LessEqual` compare so they always draw
732    /// regardless of any active vector clip.
733    mesh_overlay: wgpu::RenderPipeline,
734    /// Fixed-function blend variants of the mesh pipeline, selected by
735    /// `BlendMode`. Backdrop-dependent modes (overlay, color-dodge/burn,
736    /// hard/soft-light, exclusion, hue/saturation/color/luminosity) use the
737    /// `blend_layer` shader instead.
738    mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
739    /// Backdrop-blend composite: samples an isolated source layer and the
740    /// current target, applies the CSS blend formula selected by a uniform,
741    /// and composites premultiplied source-over.
742    blend_layer: wgpu::RenderPipeline,
743    /// Stencil increment for vector clips.
744    mesh_clip_inc: wgpu::RenderPipeline,
745    /// Stencil decrement for vector clips.
746    mesh_clip_dec: wgpu::RenderPipeline,
747    /// Projective layer composite (perspective flattening): samples a
748    /// graphics-layer texture through a 2D projective map. Drawn with a
749    /// `ProjectiveInstance` from `projective_ring`.
750    projective_layer: wgpu::RenderPipeline,
751}
752
753/// Vertex attributes for an instance buffer, as `(shader_location, byte
754/// offset, format)`. Deriving these by hand let struct field reordering
755/// silently desync the offsets from the layout.
756fn vattrs(spec: &[(u32, u64, wgpu::VertexFormat)]) -> Vec<wgpu::VertexAttribute> {
757    spec.iter()
758        .map(|&(shader_location, offset, format)| wgpu::VertexAttribute {
759            shader_location,
760            offset,
761            format,
762        })
763        .collect()
764}
765
766/// Byte offset of an instance field, so the attribute tables below stay tied
767/// to the struct layout instead of being maintained by hand.
768macro_rules! field_offset {
769    ($ty:ty, $field:ident) => {
770        std::mem::offset_of!($ty, $field) as u64
771    };
772}
773
774impl Pipelines {
775    fn create(
776        device: &wgpu::Device,
777        format: wgpu::TextureFormat,
778        sample_count: u32,
779        globals_layout: &wgpu::BindGroupLayout,
780        text_bind_layout: &wgpu::BindGroupLayout,
781        image_bind_layout_nv12: &wgpu::BindGroupLayout,
782        clip_pipeline_layout: &wgpu::PipelineLayout,
783        stencil_for_content: &wgpu::DepthStencilState,
784        stencil_for_clip_inc: &wgpu::DepthStencilState,
785        stencil_for_clip_dec: &wgpu::DepthStencilState,
786        clip_vertex_layout: &wgpu::VertexBufferLayout,
787        mesh_bind_layout: &wgpu::BindGroupLayout,
788    ) -> Self {
789        let msaa_state = wgpu::MultisampleState {
790            count: sample_count,
791            mask: !0,
792            alpha_to_coverage_enabled: false,
793        };
794
795        macro_rules! make_content_pipeline {
796            ($name:ident, $shader:literal, $inst_type:ty, $attrs:expr) => {
797                let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
798                    label: Some(concat!($shader, ".wgsl")),
799                    source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(concat!(
800                        env!("OUT_DIR"),
801                        "/",
802                        $shader,
803                        ".wgsl"
804                    )))),
805                });
806                let pipeline_layout =
807                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
808                        label: Some(concat!($shader, " pipeline layout")),
809                        bind_group_layouts: &[Some(globals_layout)],
810                        immediate_size: 0,
811                    });
812                let $name = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
813                    label: Some(concat!($shader, " pipeline")),
814                    layout: Some(&pipeline_layout),
815                    vertex: wgpu::VertexState {
816                        module: &shader_module,
817                        entry_point: Some("vs_main"),
818                        buffers: &[Some(wgpu::VertexBufferLayout {
819                            array_stride: std::mem::size_of::<$inst_type>() as u64,
820                            step_mode: wgpu::VertexStepMode::Instance,
821                            attributes: $attrs,
822                        })],
823                        compilation_options: wgpu::PipelineCompilationOptions::default(),
824                    },
825                    fragment: Some(wgpu::FragmentState {
826                        module: &shader_module,
827                        entry_point: Some("fs_main"),
828                        targets: &[Some(wgpu::ColorTargetState {
829                            format,
830                            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
831                            write_mask: wgpu::ColorWrites::ALL,
832                        })],
833                        compilation_options: wgpu::PipelineCompilationOptions::default(),
834                    }),
835                    primitive: wgpu::PrimitiveState::default(),
836                    depth_stencil: Some(stencil_for_content.clone()),
837                    multisample: msaa_state,
838                    multiview_mask: None,
839                    cache: None,
840                });
841            };
842        }
843
844        let rect_attrs = vattrs(&[
845            (
846                0,
847                field_offset!(RectInstance, xywh),
848                wgpu::VertexFormat::Float32x4,
849            ),
850            (
851                1,
852                field_offset!(RectInstance, radii),
853                wgpu::VertexFormat::Float32x4,
854            ),
855            (
856                2,
857                field_offset!(RectInstance, color0),
858                wgpu::VertexFormat::Float32x4,
859            ),
860            (
861                3,
862                field_offset!(RectInstance, color1),
863                wgpu::VertexFormat::Float32x4,
864            ),
865            (
866                4,
867                field_offset!(RectInstance, fwd_mat),
868                wgpu::VertexFormat::Float32x4,
869            ),
870            (
871                5,
872                field_offset!(RectInstance, grad),
873                wgpu::VertexFormat::Float32x4,
874            ),
875            (
876                6,
877                field_offset!(RectInstance, bits),
878                wgpu::VertexFormat::Uint32,
879            ),
880        ]);
881        let border_attrs = vattrs(&[
882            (
883                0,
884                field_offset!(BorderInstance, xywh),
885                wgpu::VertexFormat::Float32x4,
886            ),
887            (
888                1,
889                field_offset!(BorderInstance, radii),
890                wgpu::VertexFormat::Float32x4,
891            ),
892            (
893                2,
894                field_offset!(BorderInstance, color0),
895                wgpu::VertexFormat::Float32x4,
896            ),
897            (
898                3,
899                field_offset!(BorderInstance, color1),
900                wgpu::VertexFormat::Float32x4,
901            ),
902            (
903                4,
904                field_offset!(BorderInstance, fwd_mat),
905                wgpu::VertexFormat::Float32x4,
906            ),
907            (
908                5,
909                field_offset!(BorderInstance, grad),
910                wgpu::VertexFormat::Float32x4,
911            ),
912            (
913                6,
914                field_offset!(BorderInstance, bits),
915                wgpu::VertexFormat::Uint32,
916            ),
917            (
918                7,
919                field_offset!(BorderInstance, stroke),
920                wgpu::VertexFormat::Float32,
921            ),
922        ]);
923        let ellipse_attrs = vattrs(&[
924            (
925                0,
926                field_offset!(EllipseInstance, xywh),
927                wgpu::VertexFormat::Float32x4,
928            ),
929            (
930                1,
931                field_offset!(EllipseInstance, color0),
932                wgpu::VertexFormat::Float32x4,
933            ),
934            (
935                2,
936                field_offset!(EllipseInstance, color1),
937                wgpu::VertexFormat::Float32x4,
938            ),
939            (
940                3,
941                field_offset!(EllipseInstance, fwd_mat),
942                wgpu::VertexFormat::Float32x4,
943            ),
944            (
945                4,
946                field_offset!(EllipseInstance, grad),
947                wgpu::VertexFormat::Float32x4,
948            ),
949            (
950                5,
951                field_offset!(EllipseInstance, bits),
952                wgpu::VertexFormat::Uint32,
953            ),
954        ]);
955        let ellipse_border_attrs = vattrs(&[
956            (
957                0,
958                field_offset!(EllipseBorderInstance, xywh),
959                wgpu::VertexFormat::Float32x4,
960            ),
961            (
962                1,
963                field_offset!(EllipseBorderInstance, color0),
964                wgpu::VertexFormat::Float32x4,
965            ),
966            (
967                2,
968                field_offset!(EllipseBorderInstance, color1),
969                wgpu::VertexFormat::Float32x4,
970            ),
971            (
972                3,
973                field_offset!(EllipseBorderInstance, fwd_mat),
974                wgpu::VertexFormat::Float32x4,
975            ),
976            (
977                4,
978                field_offset!(EllipseBorderInstance, grad),
979                wgpu::VertexFormat::Float32x4,
980            ),
981            (
982                5,
983                field_offset!(EllipseBorderInstance, bits),
984                wgpu::VertexFormat::Uint32,
985            ),
986            (
987                6,
988                field_offset!(EllipseBorderInstance, stroke),
989                wgpu::VertexFormat::Float32,
990            ),
991            (
992                7,
993                field_offset!(EllipseBorderInstance, pad),
994                wgpu::VertexFormat::Float32,
995            ),
996        ]);
997
998        make_content_pipeline!(rects, "rect", RectInstance, &rect_attrs);
999        make_content_pipeline!(borders, "border", BorderInstance, &border_attrs);
1000        make_content_pipeline!(ellipses, "ellipse", EllipseInstance, &ellipse_attrs);
1001        make_content_pipeline!(
1002            ellipse_borders,
1003            "ellipse_border",
1004            EllipseBorderInstance,
1005            &ellipse_border_attrs
1006        );
1007
1008        let arc_attrs = vattrs(&[
1009            (
1010                0,
1011                field_offset!(ArcInstance, xywh),
1012                wgpu::VertexFormat::Float32x4,
1013            ),
1014            (
1015                1,
1016                field_offset!(ArcInstance, color0),
1017                wgpu::VertexFormat::Float32x4,
1018            ),
1019            (
1020                2,
1021                field_offset!(ArcInstance, color1),
1022                wgpu::VertexFormat::Float32x4,
1023            ),
1024            (
1025                3,
1026                field_offset!(ArcInstance, fwd_mat),
1027                wgpu::VertexFormat::Float32x4,
1028            ),
1029            (
1030                4,
1031                field_offset!(ArcInstance, grad),
1032                wgpu::VertexFormat::Float32x4,
1033            ),
1034            (
1035                5,
1036                field_offset!(ArcInstance, bits),
1037                wgpu::VertexFormat::Uint32,
1038            ),
1039            (
1040                6,
1041                field_offset!(ArcInstance, start_angle),
1042                wgpu::VertexFormat::Float32,
1043            ),
1044            (
1045                7,
1046                field_offset!(ArcInstance, sweep_angle),
1047                wgpu::VertexFormat::Float32,
1048            ),
1049            (
1050                8,
1051                field_offset!(ArcInstance, stroke),
1052                wgpu::VertexFormat::Float32,
1053            ),
1054            (
1055                9,
1056                field_offset!(ArcInstance, pad),
1057                wgpu::VertexFormat::Float32,
1058            ),
1059        ]);
1060
1061        make_content_pipeline!(arcs, "arc", ArcInstance, &arc_attrs);
1062
1063        // Text (mask)
1064        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1065            label: Some("text.wgsl"),
1066            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
1067        });
1068        // Text (color)
1069        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1070            label: Some("text_color.wgsl"),
1071            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1072                "shaders/text_color.wgsl"
1073            ))),
1074        });
1075        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1076            label: Some("text pipeline layout"),
1077            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1078            immediate_size: 0,
1079        });
1080        let glyph_vertex = wgpu::VertexBufferLayout {
1081            array_stride: std::mem::size_of::<GlyphInstance>() as u64,
1082            step_mode: wgpu::VertexStepMode::Instance,
1083            attributes: &[
1084                wgpu::VertexAttribute {
1085                    shader_location: 0,
1086                    offset: 0,
1087                    format: wgpu::VertexFormat::Float32x4,
1088                },
1089                wgpu::VertexAttribute {
1090                    shader_location: 1,
1091                    offset: 16,
1092                    format: wgpu::VertexFormat::Float32x4,
1093                },
1094                wgpu::VertexAttribute {
1095                    shader_location: 2,
1096                    offset: 32,
1097                    format: wgpu::VertexFormat::Float32x4,
1098                },
1099                wgpu::VertexAttribute {
1100                    shader_location: 3,
1101                    offset: 48,
1102                    format: wgpu::VertexFormat::Float32x4,
1103                },
1104            ],
1105        };
1106        let text_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1107            label: Some("text pipeline (mask)"),
1108            layout: Some(&text_pipeline_layout),
1109            vertex: wgpu::VertexState {
1110                module: &text_mask_shader,
1111                entry_point: Some("vs_main"),
1112                buffers: &[Some(glyph_vertex.clone())],
1113                compilation_options: wgpu::PipelineCompilationOptions::default(),
1114            },
1115            fragment: Some(wgpu::FragmentState {
1116                module: &text_mask_shader,
1117                entry_point: Some("fs_main"),
1118                targets: &[Some(wgpu::ColorTargetState {
1119                    format,
1120                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1121                    write_mask: wgpu::ColorWrites::ALL,
1122                })],
1123                compilation_options: wgpu::PipelineCompilationOptions::default(),
1124            }),
1125            primitive: wgpu::PrimitiveState::default(),
1126            depth_stencil: Some(stencil_for_content.clone()),
1127            multisample: msaa_state,
1128            multiview_mask: None,
1129            cache: None,
1130        });
1131        let text_color_pipeline =
1132            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1133                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1134                    label: Some(label),
1135                    layout: Some(&text_pipeline_layout),
1136                    vertex: wgpu::VertexState {
1137                        module: &text_color_shader,
1138                        entry_point: Some("vs_main"),
1139                        buffers: &[Some(glyph_vertex.clone())],
1140                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1141                    },
1142                    fragment: Some(wgpu::FragmentState {
1143                        module: &text_color_shader,
1144                        entry_point: Some("fs_main"),
1145                        targets: &[Some(wgpu::ColorTargetState {
1146                            format,
1147                            blend: Some(blend),
1148                            write_mask: wgpu::ColorWrites::ALL,
1149                        })],
1150                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1151                    }),
1152                    primitive: wgpu::PrimitiveState::default(),
1153                    depth_stencil: Some(stencil_for_content.clone()),
1154                    multisample: msaa_state,
1155                    multiview_mask: None,
1156                    cache: None,
1157                })
1158            };
1159        let text_color = text_color_pipeline(
1160            "text pipeline (color)",
1161            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1162        );
1163        // image_rgba reuses the text color pipeline (same vertex/bindings).
1164        let image_rgba = text_color.clone();
1165        // Fixed-function blend variants for `ImageBlended`, indexed like
1166        // `FIXED_FUNCTION_BLENDS`.
1167        let image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1168            std::array::from_fn(|index| {
1169                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1170                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1171                text_color_pipeline("image pipeline (blend)", blend)
1172            });
1173
1174        // Tinted A8 coverage composite. Same vertex attributes (GlyphInstance)
1175        // and bind groups (globals + texture/sampler) as the text color path,
1176        // sampling R8 tiles uploaded via `register_coverage_a8`.
1177        let coverage_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1178            label: Some("coverage.wgsl"),
1179            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/coverage.wgsl"))),
1180        });
1181        let coverage = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1182            label: Some("coverage pipeline (tinted a8)"),
1183            layout: Some(&text_pipeline_layout),
1184            vertex: wgpu::VertexState {
1185                module: &coverage_shader,
1186                entry_point: Some("vs_main"),
1187                buffers: &[Some(glyph_vertex.clone())],
1188                compilation_options: wgpu::PipelineCompilationOptions::default(),
1189            },
1190            fragment: Some(wgpu::FragmentState {
1191                module: &coverage_shader,
1192                entry_point: Some("fs_main"),
1193                targets: &[Some(wgpu::ColorTargetState {
1194                    format,
1195                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1196                    write_mask: wgpu::ColorWrites::ALL,
1197                })],
1198                compilation_options: wgpu::PipelineCompilationOptions::default(),
1199            }),
1200            primitive: wgpu::PrimitiveState::default(),
1201            depth_stencil: Some(stencil_for_content.clone()),
1202            multisample: msaa_state,
1203            multiview_mask: None,
1204            cache: None,
1205        });
1206
1207        let blur_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1208            label: Some("blur_separable.wgsl"),
1209            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1210                "shaders/blur_separable.wgsl"
1211            ))),
1212        });
1213        let blur_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1214            label: Some("blur pipeline layout"),
1215            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1216            immediate_size: 0,
1217        });
1218        let blur_layout = wgpu::VertexBufferLayout {
1219            array_stride: std::mem::size_of::<BlurInstance>() as u64,
1220            step_mode: wgpu::VertexStepMode::Instance,
1221            attributes: &[
1222                wgpu::VertexAttribute {
1223                    shader_location: 0,
1224                    offset: 0,
1225                    format: wgpu::VertexFormat::Float32x4,
1226                },
1227                wgpu::VertexAttribute {
1228                    shader_location: 1,
1229                    offset: 16,
1230                    format: wgpu::VertexFormat::Float32x4,
1231                },
1232                wgpu::VertexAttribute {
1233                    shader_location: 2,
1234                    offset: 32,
1235                    format: wgpu::VertexFormat::Float32x4,
1236                },
1237                wgpu::VertexAttribute {
1238                    shader_location: 3,
1239                    offset: 48,
1240                    format: wgpu::VertexFormat::Float32x2,
1241                },
1242                wgpu::VertexAttribute {
1243                    shader_location: 4,
1244                    offset: 56,
1245                    format: wgpu::VertexFormat::Float32x4,
1246                },
1247                wgpu::VertexAttribute {
1248                    shader_location: 5,
1249                    offset: 72,
1250                    format: wgpu::VertexFormat::Uint32,
1251                },
1252                wgpu::VertexAttribute {
1253                    shader_location: 6,
1254                    offset: 76,
1255                    format: wgpu::VertexFormat::Uint32,
1256                },
1257                wgpu::VertexAttribute {
1258                    shader_location: 7,
1259                    offset: 80,
1260                    format: wgpu::VertexFormat::Float32x4,
1261                },
1262            ],
1263        };
1264        let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1265            label: Some("blur pipeline"),
1266            layout: Some(&blur_pipeline_layout),
1267            vertex: wgpu::VertexState {
1268                module: &blur_shader,
1269                entry_point: Some("vs_main"),
1270                buffers: &[Some(blur_layout.clone())],
1271                compilation_options: wgpu::PipelineCompilationOptions::default(),
1272            },
1273            fragment: Some(wgpu::FragmentState {
1274                module: &blur_shader,
1275                entry_point: Some("fs_alpha"),
1276                targets: &[Some(wgpu::ColorTargetState {
1277                    format,
1278                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1279                    write_mask: wgpu::ColorWrites::ALL,
1280                })],
1281                compilation_options: wgpu::PipelineCompilationOptions::default(),
1282            }),
1283            primitive: wgpu::PrimitiveState::default(),
1284            depth_stencil: Some(stencil_for_content.clone()),
1285            multisample: msaa_state,
1286            multiview_mask: None,
1287            cache: None,
1288        });
1289
1290        let blur_content = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1291            label: Some("blur content pipeline"),
1292            layout: Some(&blur_pipeline_layout),
1293            vertex: wgpu::VertexState {
1294                module: &blur_shader,
1295                entry_point: Some("vs_main"),
1296                buffers: &[Some(blur_layout)],
1297                compilation_options: wgpu::PipelineCompilationOptions::default(),
1298            },
1299            fragment: Some(wgpu::FragmentState {
1300                module: &blur_shader,
1301                entry_point: Some("fs_color"),
1302                targets: &[Some(wgpu::ColorTargetState {
1303                    format,
1304                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1305                    write_mask: wgpu::ColorWrites::ALL,
1306                })],
1307                compilation_options: wgpu::PipelineCompilationOptions::default(),
1308            }),
1309            primitive: wgpu::PrimitiveState::default(),
1310            depth_stencil: Some(stencil_for_content.clone()),
1311            multisample: msaa_state,
1312            multiview_mask: None,
1313            cache: None,
1314        });
1315
1316        // NV12 Image Pipeline
1317        let image_nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1318            label: Some("image_nv12.wgsl"),
1319            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1320                "shaders/image_nv12.wgsl"
1321            ))),
1322        });
1323        let image_nv12_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1324            label: Some("image nv12 pipeline layout"),
1325            bind_group_layouts: &[Some(globals_layout), Some(image_bind_layout_nv12)],
1326            immediate_size: 0,
1327        });
1328        let nv12_vertex_layout = wgpu::VertexBufferLayout {
1329            array_stride: std::mem::size_of::<Nv12Instance>() as u64,
1330            step_mode: wgpu::VertexStepMode::Instance,
1331            attributes: &[
1332                wgpu::VertexAttribute {
1333                    shader_location: 0,
1334                    offset: 0,
1335                    format: wgpu::VertexFormat::Float32x4,
1336                },
1337                wgpu::VertexAttribute {
1338                    shader_location: 1,
1339                    offset: 16,
1340                    format: wgpu::VertexFormat::Float32x4,
1341                },
1342                wgpu::VertexAttribute {
1343                    shader_location: 2,
1344                    offset: 32,
1345                    format: wgpu::VertexFormat::Float32x4,
1346                },
1347                wgpu::VertexAttribute {
1348                    shader_location: 3,
1349                    offset: 48,
1350                    format: wgpu::VertexFormat::Float32,
1351                },
1352                wgpu::VertexAttribute {
1353                    shader_location: 4,
1354                    offset: 52,
1355                    format: wgpu::VertexFormat::Float32,
1356                },
1357                wgpu::VertexAttribute {
1358                    shader_location: 5,
1359                    offset: 56,
1360                    format: wgpu::VertexFormat::Float32x4,
1361                },
1362            ],
1363        };
1364        let nv12_pipeline =
1365            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1366                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1367                    label: Some(label),
1368                    layout: Some(&image_nv12_layout),
1369                    vertex: wgpu::VertexState {
1370                        module: &image_nv12_shader,
1371                        entry_point: Some("vs_main"),
1372                        buffers: &[Some(nv12_vertex_layout.clone())],
1373                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1374                    },
1375                    fragment: Some(wgpu::FragmentState {
1376                        module: &image_nv12_shader,
1377                        entry_point: Some("fs_main"),
1378                        targets: &[Some(wgpu::ColorTargetState {
1379                            format,
1380                            blend: Some(blend),
1381                            write_mask: wgpu::ColorWrites::ALL,
1382                        })],
1383                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1384                    }),
1385                    primitive: wgpu::PrimitiveState::default(),
1386                    depth_stencil: Some(stencil_for_content.clone()),
1387                    multisample: msaa_state,
1388                    multiview_mask: None,
1389                    cache: None,
1390                })
1391            };
1392        let image_nv12 = nv12_pipeline(
1393            "image nv12 pipeline",
1394            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1395        );
1396        let image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1397            std::array::from_fn(|index| {
1398                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1399                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1400                nv12_pipeline("image nv12 pipeline (blend)", blend)
1401            });
1402
1403        let clip_color_target = wgpu::ColorTargetState {
1404            format,
1405            blend: None,
1406            write_mask: wgpu::ColorWrites::empty(),
1407        };
1408
1409        // Clipping
1410        let clip_shader_bin = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1411            label: Some("clip_round_rect_bin.wgsl"),
1412            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1413                "shaders/clip_round_rect_bin.wgsl"
1414            ))),
1415        });
1416        let clip_bin = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1417            label: Some("clip pipeline (bin)"),
1418            layout: Some(clip_pipeline_layout),
1419            vertex: wgpu::VertexState {
1420                module: &clip_shader_bin,
1421                entry_point: Some("vs_main"),
1422                buffers: &[Some(clip_vertex_layout.clone())],
1423                compilation_options: wgpu::PipelineCompilationOptions::default(),
1424            },
1425            fragment: Some(wgpu::FragmentState {
1426                module: &clip_shader_bin,
1427                entry_point: Some("fs_main"),
1428                targets: &[Some(clip_color_target.clone())],
1429                compilation_options: wgpu::PipelineCompilationOptions::default(),
1430            }),
1431            primitive: wgpu::PrimitiveState::default(),
1432            depth_stencil: Some(stencil_for_clip_inc.clone()),
1433            multisample: wgpu::MultisampleState {
1434                count: sample_count,
1435                mask: !0,
1436                alpha_to_coverage_enabled: false,
1437            },
1438            multiview_mask: None,
1439            cache: None,
1440        });
1441        let clip_dec = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1442            label: Some("clip pipeline (dec)"),
1443            layout: Some(clip_pipeline_layout),
1444            vertex: wgpu::VertexState {
1445                module: &clip_shader_bin,
1446                entry_point: Some("vs_main"),
1447                buffers: &[Some(clip_vertex_layout.clone())],
1448                compilation_options: wgpu::PipelineCompilationOptions::default(),
1449            },
1450            fragment: Some(wgpu::FragmentState {
1451                module: &clip_shader_bin,
1452                entry_point: Some("fs_main"),
1453                targets: &[Some(clip_color_target.clone())],
1454                compilation_options: wgpu::PipelineCompilationOptions::default(),
1455            }),
1456            primitive: wgpu::PrimitiveState::default(),
1457            depth_stencil: Some(stencil_for_clip_dec.clone()),
1458            multisample: wgpu::MultisampleState {
1459                count: sample_count,
1460                mask: !0,
1461                alpha_to_coverage_enabled: false,
1462            },
1463            multiview_mask: None,
1464            cache: None,
1465        });
1466
1467        let slug = Some(slug::create_pipeline(
1468            device,
1469            format,
1470            sample_count,
1471            stencil_for_content,
1472        ));
1473
1474        // Tessellated vector mesh pipeline (host-supplied vertex/index data).
1475        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1476            label: Some("mesh.wgsl"),
1477            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/mesh.wgsl"))),
1478        });
1479        let mesh_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1480            label: Some("mesh pipeline layout"),
1481            bind_group_layouts: &[Some(globals_layout), Some(mesh_bind_layout)],
1482            immediate_size: 0,
1483        });
1484        let mesh_vertex_layout = wgpu::VertexBufferLayout {
1485            array_stride: std::mem::size_of::<MeshVertex>() as u64,
1486            step_mode: wgpu::VertexStepMode::Vertex,
1487            attributes: &[
1488                wgpu::VertexAttribute {
1489                    shader_location: 0,
1490                    offset: 0,
1491                    format: wgpu::VertexFormat::Float32x2,
1492                },
1493                wgpu::VertexAttribute {
1494                    shader_location: 1,
1495                    offset: 8,
1496                    format: wgpu::VertexFormat::Float32x4,
1497                },
1498                wgpu::VertexAttribute {
1499                    shader_location: 2,
1500                    offset: 24,
1501                    format: wgpu::VertexFormat::Float32x2,
1502                },
1503            ],
1504        };
1505        let make_mesh_pipeline =
1506            |label: &str, depth: &wgpu::DepthStencilState, color: &wgpu::ColorTargetState| {
1507                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1508                    label: Some(label),
1509                    layout: Some(&mesh_pipeline_layout),
1510                    vertex: wgpu::VertexState {
1511                        module: &mesh_shader,
1512                        entry_point: Some("vs_main"),
1513                        buffers: &[Some(mesh_vertex_layout.clone())],
1514                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1515                    },
1516                    fragment: Some(wgpu::FragmentState {
1517                        module: &mesh_shader,
1518                        entry_point: Some("fs_main"),
1519                        targets: &[Some(color.clone())],
1520                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1521                    }),
1522                    primitive: wgpu::PrimitiveState {
1523                        topology: wgpu::PrimitiveTopology::TriangleList,
1524                        ..Default::default()
1525                    },
1526                    depth_stencil: Some(depth.clone()),
1527                    multisample: msaa_state,
1528                    multiview_mask: None,
1529                    cache: None,
1530                })
1531            };
1532        let mesh_color_target = wgpu::ColorTargetState {
1533            format,
1534            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1535            write_mask: wgpu::ColorWrites::ALL,
1536        };
1537        let mut stencil_for_mesh = stencil_for_content.clone();
1538        stencil_for_mesh.stencil.front.compare = wgpu::CompareFunction::Equal;
1539        stencil_for_mesh.stencil.back.compare = wgpu::CompareFunction::Equal;
1540        let mut stencil_for_overlay = stencil_for_content.clone();
1541        stencil_for_overlay.stencil.front.compare = wgpu::CompareFunction::LessEqual;
1542        stencil_for_overlay.stencil.back.compare = wgpu::CompareFunction::LessEqual;
1543        let mesh = make_mesh_pipeline("mesh pipeline", &stencil_for_mesh, &mesh_color_target);
1544        let mesh_overlay = make_mesh_pipeline(
1545            "mesh overlay pipeline",
1546            &stencil_for_overlay,
1547            &mesh_color_target,
1548        );
1549        let mesh_clip_inc = make_mesh_pipeline(
1550            "mesh clip (inc) pipeline",
1551            stencil_for_clip_inc,
1552            &clip_color_target,
1553        );
1554        let mesh_clip_dec = make_mesh_pipeline(
1555            "mesh clip (dec) pipeline",
1556            stencil_for_clip_dec,
1557            &clip_color_target,
1558        );
1559        let mesh_blend_target = |blend: wgpu::BlendState| wgpu::ColorTargetState {
1560            format,
1561            blend: Some(blend),
1562            write_mask: wgpu::ColorWrites::ALL,
1563        };
1564        let mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1565            std::array::from_fn(|index| {
1566                let mode = FIXED_FUNCTION_BLENDS[index];
1567                let blend = fixed_function_blend(mode).unwrap_or(wgpu::BlendState::REPLACE);
1568                make_mesh_pipeline(
1569                    match mode {
1570                        repose_core::BlendMode::Add => "mesh pipeline (add)",
1571                        repose_core::BlendMode::Multiply => "mesh pipeline (multiply)",
1572                        repose_core::BlendMode::Screen => "mesh pipeline (screen)",
1573                        repose_core::BlendMode::Darken => "mesh pipeline (darken)",
1574                        _ => "mesh pipeline (lighten)",
1575                    },
1576                    &stencil_for_mesh,
1577                    &mesh_blend_target(blend),
1578                )
1579            });
1580        // Projective layer composite (perspective flattening). Same
1581        // bind groups as the text/image path (globals + layer texture), with
1582        // per-instance projected corners. Like `image_rgba` it draws into the
1583        // parent target, so it shares the content stencil state.
1584        let projective_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1585            label: Some("projective_layer.wgsl"),
1586            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1587                "shaders/projective_layer.wgsl"
1588            ))),
1589        });
1590        let projective_pipeline_layout =
1591            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1592                label: Some("projective layer pipeline layout"),
1593                bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1594                immediate_size: 0,
1595            });
1596        let projective_vertex_layout = wgpu::VertexBufferLayout {
1597            array_stride: std::mem::size_of::<ProjectiveInstance>() as u64,
1598            step_mode: wgpu::VertexStepMode::Instance,
1599            attributes: &[
1600                wgpu::VertexAttribute {
1601                    shader_location: 0,
1602                    offset: 0,
1603                    format: wgpu::VertexFormat::Float32x2,
1604                },
1605                wgpu::VertexAttribute {
1606                    shader_location: 1,
1607                    offset: 8,
1608                    format: wgpu::VertexFormat::Float32x2,
1609                },
1610                wgpu::VertexAttribute {
1611                    shader_location: 2,
1612                    offset: 16,
1613                    format: wgpu::VertexFormat::Float32x2,
1614                },
1615                wgpu::VertexAttribute {
1616                    shader_location: 3,
1617                    offset: 24,
1618                    format: wgpu::VertexFormat::Float32x2,
1619                },
1620                wgpu::VertexAttribute {
1621                    shader_location: 4,
1622                    offset: 32,
1623                    format: wgpu::VertexFormat::Float32x4,
1624                },
1625                wgpu::VertexAttribute {
1626                    shader_location: 5,
1627                    offset: 48,
1628                    format: wgpu::VertexFormat::Float32x4,
1629                },
1630                wgpu::VertexAttribute {
1631                    shader_location: 6,
1632                    offset: 64,
1633                    format: wgpu::VertexFormat::Float32,
1634                },
1635            ],
1636        };
1637        let projective_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1638            label: Some("projective layer composite pipeline"),
1639            layout: Some(&projective_pipeline_layout),
1640            vertex: wgpu::VertexState {
1641                module: &projective_shader,
1642                entry_point: Some("vs_main"),
1643                buffers: &[Some(projective_vertex_layout)],
1644                compilation_options: wgpu::PipelineCompilationOptions::default(),
1645            },
1646            fragment: Some(wgpu::FragmentState {
1647                module: &projective_shader,
1648                entry_point: Some("fs_main"),
1649                targets: &[Some(wgpu::ColorTargetState {
1650                    format,
1651                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1652                    write_mask: wgpu::ColorWrites::ALL,
1653                })],
1654                compilation_options: wgpu::PipelineCompilationOptions::default(),
1655            }),
1656            primitive: wgpu::PrimitiveState::default(),
1657            depth_stencil: Some(stencil_for_content.clone()),
1658            multisample: msaa_state,
1659            multiview_mask: None,
1660            cache: None,
1661        });
1662
1663        let blend_layer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1664            label: Some("blend_layer.wgsl"),
1665            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1666                "shaders/blend_layer.wgsl"
1667            ))),
1668        });
1669        let blend_layer_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1670            label: Some("blend layer pipeline layout"),
1671            bind_group_layouts: &[
1672                Some(globals_layout),
1673                Some(text_bind_layout),
1674                Some(text_bind_layout),
1675            ],
1676            immediate_size: 0,
1677        });
1678        let blend_vertex_layout = wgpu::VertexBufferLayout {
1679            array_stride: std::mem::size_of::<BlendInstance>() as u64,
1680            step_mode: wgpu::VertexStepMode::Instance,
1681            attributes: &[
1682                wgpu::VertexAttribute {
1683                    shader_location: 0,
1684                    offset: 0,
1685                    format: wgpu::VertexFormat::Float32x4,
1686                },
1687                wgpu::VertexAttribute {
1688                    shader_location: 1,
1689                    offset: 16,
1690                    format: wgpu::VertexFormat::Float32x4,
1691                },
1692                wgpu::VertexAttribute {
1693                    shader_location: 2,
1694                    offset: 32,
1695                    format: wgpu::VertexFormat::Float32x4,
1696                },
1697                wgpu::VertexAttribute {
1698                    shader_location: 3,
1699                    offset: 48,
1700                    format: wgpu::VertexFormat::Float32x4,
1701                },
1702                wgpu::VertexAttribute {
1703                    shader_location: 4,
1704                    offset: 64,
1705                    format: wgpu::VertexFormat::Uint32,
1706                },
1707            ],
1708        };
1709        let blend_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1710            label: Some("blend layer pipeline"),
1711            layout: Some(&blend_layer_layout),
1712            vertex: wgpu::VertexState {
1713                module: &blend_layer_shader,
1714                entry_point: Some("vs_main"),
1715                buffers: &[Some(blend_vertex_layout)],
1716                compilation_options: wgpu::PipelineCompilationOptions::default(),
1717            },
1718            fragment: Some(wgpu::FragmentState {
1719                module: &blend_layer_shader,
1720                entry_point: Some("fs_main"),
1721                targets: &[Some(wgpu::ColorTargetState {
1722                    format,
1723                    blend: None,
1724                    write_mask: wgpu::ColorWrites::ALL,
1725                })],
1726                compilation_options: wgpu::PipelineCompilationOptions::default(),
1727            }),
1728            primitive: wgpu::PrimitiveState::default(),
1729            depth_stencil: Some(stencil_for_content.clone()),
1730            multisample: msaa_state,
1731            multiview_mask: None,
1732            cache: None,
1733        });
1734
1735        Self {
1736            rects,
1737            borders,
1738            ellipses,
1739            ellipse_borders,
1740            arcs,
1741            text_mask,
1742            text_color,
1743            image_rgba,
1744            image_blends,
1745            image_nv12,
1746            image_nv12_blends,
1747            coverage,
1748            blur,
1749            blur_content,
1750            clip_bin,
1751            clip_dec,
1752            slug,
1753            mesh,
1754            mesh_blends,
1755            blend_layer,
1756            mesh_overlay,
1757            mesh_clip_inc,
1758            mesh_clip_dec,
1759            projective_layer,
1760        }
1761    }
1762}
1763
1764/// AABB of a rect under the *plain affine* part of a transform (linear +
1765/// translation, no origin re-pivot).
1766fn affine_aabb(transform: &Transform, rect: &repose_core::Rect) -> repose_core::Rect {
1767    let m = transform.linear();
1768    let (tx, ty) = (transform.translate_x, transform.translate_y);
1769    let corners = [
1770        (rect.x, rect.y),
1771        (rect.x + rect.w, rect.y),
1772        (rect.x, rect.y + rect.h),
1773        (rect.x + rect.w, rect.y + rect.h),
1774    ];
1775    let mut min_x = f32::INFINITY;
1776    let mut min_y = f32::INFINITY;
1777    let mut max_x = f32::NEG_INFINITY;
1778    let mut max_y = f32::NEG_INFINITY;
1779    for (x, y) in corners {
1780        let wx = m[0] * x + m[1] * y + tx;
1781        let wy = m[2] * x + m[3] * y + ty;
1782        min_x = min_x.min(wx);
1783        min_y = min_y.min(wy);
1784        max_x = max_x.max(wx);
1785        max_y = max_y.max(wy);
1786    }
1787    repose_core::Rect {
1788        x: min_x,
1789        y: min_y,
1790        w: (max_x - min_x).max(0.0),
1791        h: (max_y - min_y).max(0.0),
1792    }
1793}
1794
1795/// Convert a local-space rect + transform to NDC center-based position+size
1796/// plus the forward rotation/shear 2x2 (row-major `[m00, m01, m10, m11]`,
1797/// scale-free: scale rides in the NDC size). Shaders apply it to quad corners
1798/// and its adjugate/determinant inverse to sample positions.
1799fn rect_to_instance_ndc(
1800    rect: repose_core::Rect,
1801    transform: &Transform,
1802    fb_w: f32,
1803    fb_h: f32,
1804) -> ([f32; 4], [f32; 4]) {
1805    let cx = rect.x + rect.w * 0.5;
1806    let cy = rect.y + rect.h * 0.5;
1807
1808    let m = transform.linear();
1809    let tx = m[0] * cx + m[1] * cy + transform.translate_x;
1810    let ty = m[2] * cx + m[3] * cy + transform.translate_y;
1811
1812    let ndc_cx = (tx / fb_w) * 2.0 - 1.0;
1813    let ndc_cy = 1.0 - (ty / fb_h) * 2.0;
1814    // NDC size (after scale only, no rotation - rotation is done in shader)
1815    let ndc_w = (rect.w * transform.scale_x / fb_w) * 2.0;
1816    let ndc_h = (rect.h * transform.scale_y / fb_h) * 2.0;
1817
1818    ([ndc_cx, ndc_cy, ndc_w, ndc_h], forward_rs_mat(transform))
1819}
1820
1821/// Forward rotation+shear 2x2 (row-major, scale-free) for instance
1822/// attributes. Identity for untransformed content; degenerate shear (only
1823/// from absurd inputs) falls back to identity.
1824fn forward_rs_mat(transform: &Transform) -> [f32; 4] {
1825    let c = transform.rotate.cos();
1826    let s = transform.rotate.sin();
1827    let (hx, hy) = (transform.shear_x, transform.shear_y);
1828    let m = [c - s * hy, c * hx - s, s + c * hy, s * hx + c];
1829    if (m[0] * m[3] - m[1] * m[2]).abs() < 1e-6 {
1830        return [1.0, 0.0, 0.0, 1.0];
1831    }
1832    m
1833}
1834
1835/// A segment of the frame that draws into a single render target.
1836struct Pass {
1837    id: u64,
1838    target: PassTarget,
1839    /// The scissor to apply when the pass is opened.
1840    initial_scissor: (u32, u32, u32, u32),
1841    /// The currently active scissor in the translated pass. `None` means an
1842    /// empty intersection and all draws must be suppressed.
1843    active_scissor: Option<(u32, u32, u32, u32)>,
1844    /// `None` means `LoadOp::Load` (resume existing content);
1845    /// `Some(c)` means `LoadOp::Clear(c)`.
1846    clear_color: Option<[f32; 4]>,
1847    cmds: Vec<Cmd>,
1848}
1849
1850struct LayerState {
1851    layer_id: u32,
1852    parent_target: PassTarget,
1853    parent_scissors: Vec<repose_core::Rect>,
1854    parent_root_clip: repose_core::Rect,
1855    parent_size: (f32, f32),
1856    parent_transform: Transform,
1857    parent_scissor: Option<(u32, u32, u32, u32)>,
1858    parent_clips: Vec<ActiveClip>,
1859    alpha: f32,
1860    blur: (f32, f32),
1861    rectangle_edge: bool,
1862}
1863
1864/// One translator-flattened perspective layer (see
1865/// `push_perspective_layer`): the projective map and everything needed to
1866/// restore the parent target on the matching pop.
1867struct FlattenRecord {
1868    /// `transform_stack.len()` before this flatten pushed its two entries
1869    /// (stripped affine + layer-local shift).
1870    stack_len: usize,
1871    layer_id: u32,
1872    /// Full projective map (row-major 3x3): affine ancestors over the
1873    /// perspective node, in parent-target coordinates.
1874    map: [f32; 9],
1875    /// Layer rect in the parent target's coordinates.
1876    layer_rect: repose_core::Rect,
1877    saved_scissor_stack: Vec<repose_core::Rect>,
1878    saved_root: repose_core::Rect,
1879    saved_size: (f32, f32),
1880    saved_active_scissor: Option<(u32, u32, u32, u32)>,
1881    saved_clips: Vec<ActiveClip>,
1882}
1883
1884/// First translator-owned flatten layer id. Producer ids start at 1 per
1885/// scene, so this range never collides; ids are drained from the layer pool
1886/// after each frame, so reuse across frames is safe.
1887const FLATTEN_ID_BASE: u32 = 0xF000_0000;
1888const MAX_GRAPHICS_LAYERS: usize = 128;
1889const MAX_GRAPHICS_LAYER_BYTES: u64 = 256 * 1024 * 1024;
1890const MAX_TRANSIENT_GRAPHICS_LAYERS: usize = 64;
1891const MAX_TRANSIENT_GRAPHICS_LAYER_BYTES: u64 = 128 * 1024 * 1024;
1892const STENCIL_BASE: u32 = 128;
1893const STENCIL_MAX_DEPTH: u32 = 127;
1894
1895#[allow(non_snake_case)]
1896enum Cmd {
1897    ClipPush {
1898        off: u64,
1899        cnt: u32,
1900        scissor: (u32, u32, u32, u32),
1901        difference: bool,
1902        applied: bool,
1903    },
1904    ClipPop {
1905        off: u64,
1906        cnt: u32,
1907        /// Scissor restored for the draws that follow the pop (the parent clip).
1908        scissor: (u32, u32, u32, u32),
1909        /// Scissor the matching push drew its stencil pass with; the decrement
1910        /// must use it so both passes touch the same pixels.
1911        stencil_scissor: (u32, u32, u32, u32),
1912        difference: bool,
1913        applied: bool,
1914    },
1915    Rect {
1916        off: u64,
1917        cnt: u32,
1918    },
1919    Border {
1920        off: u64,
1921        cnt: u32,
1922    },
1923    Ellipse {
1924        off: u64,
1925        cnt: u32,
1926    },
1927    EllipseBorder {
1928        off: u64,
1929        cnt: u32,
1930    },
1931    Arc {
1932        off: u64,
1933        cnt: u32,
1934    },
1935    GlyphsMask {
1936        off: u64,
1937        cnt: u32,
1938    },
1939    GlyphsColor {
1940        off: u64,
1941        cnt: u32,
1942    },
1943    GlyphsVector {
1944        off: u64,
1945        cnt: u32,
1946    },
1947    ImageRgba {
1948        off: u64,
1949        cnt: u32,
1950        handle: u64,
1951        filter: ImageFilter,
1952        /// Bind to the repeating samplers so `ImageFit::Tile` UVs wrap.
1953        tile: bool,
1954        /// Fixed-function blend mode; `Alpha` uses `image_rgba`.
1955        blend: repose_core::BlendMode,
1956    },
1957    /// Composite a tinted A8 coverage tile (`SceneNode::Coverage`). The
1958    /// instance lives in `self.glyph_color.ring` (a `GlyphInstance`); the
1959    /// bind comes from the coverage registry.
1960    Coverage {
1961        off: u64,
1962        cnt: u32,
1963        handle: u64,
1964    },
1965    ImageNv12 {
1966        off: u64,
1967        cnt: u32,
1968        handle: u64,
1969        filter: ImageFilter,
1970        /// Fixed-function blend mode; `Alpha` uses `image_nv12`.
1971        blend: repose_core::BlendMode,
1972    },
1973    /// Composite a previously-rendered graphics layer back into the
1974    /// current target as a textured quad. The quad's vertex buffer
1975    /// lives in `self.glyph_color.ring` (a `GlyphInstance`).
1976    CompositeLayer {
1977        off: u64,
1978        cnt: u32,
1979        layer_id: u32,
1980    },
1981    /// Separable gaussian blur, alpha channel only: the horizontal pass into a
1982    /// scratch layer, and the vertical pass that tints the result as a drop
1983    /// shadow. `layer_id` names the texture to sample, which is the source
1984    /// layer for the first pass and the scratch for the second. The quad's
1985    /// vertex buffer lives in `self.blur_ring` (a `BlurInstance`).
1986    CompositeShadow {
1987        off: u64,
1988        cnt: u32,
1989        layer_id: u32,
1990    },
1991    /// Separable gaussian blur over full RGBA, for `Modifier::blur`. Two
1992    /// stages exactly like [`Cmd::CompositeShadow`].
1993    CompositeBlur {
1994        off: u64,
1995        cnt: u32,
1996        layer_id: u32,
1997    },
1998    /// Composite a flattened perspective layer through its projective map.
1999    /// The instance lives in `self.projective_ring` (a `ProjectiveInstance`
2000    /// with CPU-projected NDC corners); sampled from the layer's texture
2001    /// with perspective-correct UVs by the `projective_layer` pipeline.
2002    CompositeProjective {
2003        off: u64,
2004        cnt: u32,
2005        layer_id: u32,
2006    },
2007    /// Draw a tessellated vector mesh (solid or gradient paint).
2008    VectorMesh {
2009        mesh: MeshGpu,
2010        uoff: u64,
2011        blend: repose_core::BlendMode,
2012    },
2013    /// Composite an isolated source layer over the current target with a
2014    /// backdrop-dependent CSS blend mode. The shader samples both textures
2015    /// and composites source-over by hand, so the pass runs REPLACE.
2016    /// `parent` records which target the composite draws into (surface or
2017    /// layer); the instance NDC is mapped against it, so any parent origin
2018    /// works.
2019    BlendLayer {
2020        off: u64,
2021        cnt: u32,
2022        src_layer: u32,
2023        dst_layer: Option<u32>,
2024        parent: PassTarget,
2025        scissor: (u32, u32, u32, u32),
2026    },
2027    /// Draw a screen-space overlay mesh (identity transform, device pixels).
2028    VectorOverlay {
2029        mesh: MeshGpu,
2030        uoff: u64,
2031    },
2032    /// Increment the stencil buffer with a tessellated vector mask.
2033    /// `difference` marks an inverse (`\iclip`-style) mask: content draws
2034    /// *outside* it. The counting still balances (push increments, pop
2035    /// decrements); only the depth bookkeeping differs (see executor).
2036    VectorClipPush {
2037        mesh: MeshGpu,
2038        uoff: u64,
2039        scissor: (u32, u32, u32, u32),
2040        difference: bool,
2041        applied: bool,
2042    },
2043    /// Decrement the stencil buffer with the matching vector mask.
2044    VectorClipPop {
2045        mesh: MeshGpu,
2046        uoff: u64,
2047        scissor: (u32, u32, u32, u32),
2048        stencil_scissor: (u32, u32, u32, u32),
2049        difference: bool,
2050        applied: bool,
2051    },
2052    Callback {
2053        rect: repose_core::Rect,
2054        clip_rect: repose_core::Rect,
2055        scissor: (u32, u32, u32, u32),
2056        restore_scissor: (u32, u32, u32, u32),
2057        callback_id: usize,
2058        payload: repose_core::PaintCallbackPayload,
2059    },
2060}
2061
2062/// A registered A8 coverage tile: single-channel mask sampled as coverage
2063/// by `SceneNode::Coverage`. Tiles are immutable; producers re-register on
2064/// geometry change and `remove_coverage` stale handles (unused tiles also
2065/// age out via the image eviction policy).
2066struct CoverageTex {
2067    // Held to keep the GPU texture alive (freed on remove/evict).
2068    #[allow(dead_code)]
2069    tex: wgpu::Texture,
2070    bind: wgpu::BindGroup,
2071    w: u32,
2072    h: u32,
2073    last_used_frame: u64,
2074    bytes: u64,
2075}
2076
2077struct ImageBinds {
2078    linear: wgpu::BindGroup,
2079    nearest: wgpu::BindGroup,
2080    /// `ImageFit::Tile` variants, bound to repeating samplers.
2081    tile_linear: wgpu::BindGroup,
2082    tile_nearest: wgpu::BindGroup,
2083}
2084
2085impl ImageBinds {
2086    fn get(&self, filter: ImageFilter, tile: bool) -> &wgpu::BindGroup {
2087        match (filter, tile) {
2088            (ImageFilter::Linear, false) => &self.linear,
2089            (ImageFilter::Nearest, false) => &self.nearest,
2090            (ImageFilter::Linear, true) => &self.tile_linear,
2091            (ImageFilter::Nearest, true) => &self.tile_nearest,
2092        }
2093    }
2094}
2095
2096enum ImageTex {
2097    Rgba {
2098        tex: wgpu::Texture,
2099        binds: ImageBinds,
2100        w: u32,
2101        h: u32,
2102        format: wgpu::TextureFormat,
2103        last_used_frame: u64,
2104        bytes: u64,
2105    },
2106    /// For a user-provided texture view.
2107    User {
2108        binds: ImageBinds,
2109        w: u32,
2110        h: u32,
2111        last_used_frame: u64,
2112        bytes: u64,
2113    },
2114    Nv12 {
2115        tex_y: wgpu::Texture,
2116        tex_uv: wgpu::Texture,
2117        binds: ImageBinds,
2118        yuv_buf: wgpu::Buffer,
2119        w: u32,
2120        h: u32,
2121        pixel_format: PixelFormat,
2122        fourcc: Option<u32>,
2123        color_info: ColorInfo,
2124        external: bool,
2125        last_used_frame: u64,
2126        bytes: u64,
2127    },
2128}
2129
2130impl ImageTex {
2131    fn evictable(&self) -> bool {
2132        match self {
2133            Self::Rgba { .. } => true,
2134            Self::User { .. } => false,
2135            Self::Nv12 { external, .. } => !*external,
2136        }
2137    }
2138}
2139
2140#[derive(Clone)]
2141struct RetainedImage {
2142    w: u32,
2143    h: u32,
2144    format: wgpu::TextureFormat,
2145    rgba: Vec<u8>,
2146    last_used_frame: u64,
2147}
2148
2149struct ResolvedImageSource {
2150    width: f32,
2151    height: f32,
2152    uv_min: [f32; 2],
2153    uv_max: [f32; 2],
2154}
2155
2156fn resolve_image_source(
2157    source_rect: Option<ImageSourceRect>,
2158    image_width: u32,
2159    image_height: u32,
2160) -> Option<ResolvedImageSource> {
2161    if image_width == 0 || image_height == 0 {
2162        return None;
2163    }
2164    let Some(source) = source_rect else {
2165        return Some(ResolvedImageSource {
2166            width: image_width as f32,
2167            height: image_height as f32,
2168            uv_min: [0.0, 0.0],
2169            uv_max: [1.0, 1.0],
2170        });
2171    };
2172    if source.is_empty() {
2173        return None;
2174    }
2175    let right = source.x.checked_add(source.width)?;
2176    let bottom = source.y.checked_add(source.height)?;
2177    if right > image_width || bottom > image_height {
2178        return None;
2179    }
2180    Some(ResolvedImageSource {
2181        width: source.width as f32,
2182        height: source.height as f32,
2183        uv_min: [
2184            source.x as f32 / image_width as f32,
2185            source.y as f32 / image_height as f32,
2186        ],
2187        uv_max: [
2188            right as f32 / image_width as f32,
2189            bottom as f32 / image_height as f32,
2190        ],
2191    })
2192}
2193
2194/// Draw rect and UV range for `fit` inside `rect`.
2195///
2196/// Mirrors Compose's `PainterNode.draw`: scale the source by
2197/// `ContentScale.computeScaleFactor`, align the result within the bounds, then
2198/// clip to those bounds. Clipping remaps the UVs rather than pushing a clip
2199/// rect, matching what Compose's `clipToBounds` and Godot's `clip_contents`
2200/// produce visually without the extra pass. Modes that scale past an edge
2201/// (`Cover`, `FitWidth`, `FitHeight`, `None`) rely on it to stay in bounds.
2202fn image_fit_geometry(
2203    rect: repose_core::Rect,
2204    fit: ImageFit,
2205    alignment: ImageAlignment,
2206    source: &ResolvedImageSource,
2207) -> Option<(repose_core::Rect, [f32; 4])> {
2208    let src_w = source.width;
2209    let src_h = source.height;
2210    let dst_w = rect.w.max(0.0);
2211    let dst_h = rect.h.max(0.0);
2212    if src_w <= 0.0 || src_h <= 0.0 || dst_w <= 0.0 || dst_h <= 0.0 {
2213        return None;
2214    }
2215
2216    // Slack placement inside the destination rect: `alignment` picks which
2217    // corner the fitted image hugs (CSS `object-position`).
2218    let slack = |content: f32, extent: f32| match alignment {
2219        ImageAlignment::Center => (extent - content) * 0.5,
2220        ImageAlignment::Begin => 0.0,
2221        ImageAlignment::End => extent - content,
2222    };
2223
2224    // A tiled draw covers the bounds at native resolution, so its UVs run past
2225    // 1.0 and the sampler wraps (see address-mode selection at draw time).
2226    let tiled = matches!(fit, ImageFit::Tile);
2227    let (scale_x, scale_y) = fit.scale_factor((src_w, src_h), (dst_w, dst_h));
2228    let drawn = if tiled {
2229        repose_core::Rect {
2230            x: rect.x,
2231            y: rect.y,
2232            w: dst_w,
2233            h: dst_h,
2234        }
2235    } else {
2236        let (w, h) = (src_w * scale_x, src_h * scale_y);
2237        repose_core::Rect {
2238            x: rect.x + slack(w, dst_w),
2239            y: rect.y + slack(h, dst_h),
2240            w,
2241            h,
2242        }
2243    };
2244
2245    let uv_min = source.uv_min;
2246    let uv_max = source.uv_max;
2247    let repeats = if tiled {
2248        [dst_w / src_w, dst_h / src_h]
2249    } else {
2250        [1.0, 1.0]
2251    };
2252    let mut uv = [
2253        uv_min[0],
2254        uv_max[1],
2255        uv_min[0] + (uv_max[0] - uv_min[0]) * repeats[0],
2256        uv_max[1] - (uv_max[1] - uv_min[1]) * repeats[1],
2257    ];
2258
2259    let clipped = intersect(drawn, rect);
2260    if clipped.w <= 0.0 || clipped.h <= 0.0 {
2261        return None;
2262    }
2263    let du = uv[2] - uv[0];
2264    let dv = uv[3] - uv[1];
2265    uv = [
2266        uv[0] + du * (clipped.x - drawn.x) / drawn.w,
2267        uv[1] + dv * (clipped.y - drawn.y) / drawn.h,
2268        uv[0] + du * (clipped.x + clipped.w - drawn.x) / drawn.w,
2269        uv[1] + dv * (clipped.y + clipped.h - drawn.y) / drawn.h,
2270    ];
2271    Some((clipped, uv))
2272}
2273
2274struct AtlasA8 {
2275    tex: wgpu::Texture,
2276    view: wgpu::TextureView,
2277    sampler: wgpu::Sampler,
2278    size: u32,
2279    next_x: u32,
2280    next_y: u32,
2281    row_h: u32,
2282    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2283    /// Summed `w * h` of every live entry. The pixel budget is what actually
2284    /// binds a shelf packer, so eviction trims on area, not entry count.
2285    used_area: u64,
2286}
2287
2288struct AtlasRGBA {
2289    tex: wgpu::Texture,
2290    view: wgpu::TextureView,
2291    sampler: wgpu::Sampler,
2292    size: u32,
2293    next_x: u32,
2294    next_y: u32,
2295    row_h: u32,
2296    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2297    used_area: u64,
2298}
2299
2300/// Work an atlas could not do yet, applied at the next frame boundary. Both
2301/// growth and compaction re-pack the atlas, which would invalidate UVs already
2302/// batched into the frame in flight, so neither may run mid-frame.
2303#[derive(Default, Clone, Copy)]
2304struct AtlasPending {
2305    /// A cell did not fit and the atlas is still below its size cap.
2306    grow: bool,
2307    /// The atlas is at its cap, so the only remedy left is dropping cold
2308    /// entries.
2309    compact: bool,
2310}
2311
2312#[derive(Clone, Copy)]
2313struct GlyphInfo {
2314    u0: f32,
2315    v0: f32,
2316    u1: f32,
2317    v1: f32,
2318    w: f32,
2319    h: f32,
2320    /// `cache_touch_clock` at the last request, and the frame this glyph was
2321    /// last batched into. Entries in flight are never evicted: the instance
2322    /// buffer already holds their UVs, and a repack would move them.
2323    last_touch: u64,
2324    last_used_frame: u64,
2325}
2326
2327#[derive(Clone, Hash, PartialEq, Eq)]
2328struct TextShapeKey {
2329    text: Arc<str>,
2330    px_bits: u32,
2331    line_height_bits: u32,
2332    font_family: Option<&'static str>,
2333    font_weight: u16,
2334    font_style: u8,
2335    letter_spacing_bits: u32,
2336    variation: Option<Arc<str>>,
2337    font_generation: u64,
2338}
2339
2340struct TextShapeCacheEntry {
2341    glyphs: Arc<[repose_text::ShapedGlyph]>,
2342    last_used_frame: u64,
2343}
2344
2345/// Instance data for one shape. The brush enums share `bits` rather than
2346/// taking a varying each, and `grad_p0`/`grad_p1` merge into one `grad`
2347/// vector, to keep the fragment stage inside the varying budget that
2348/// `field_offset!` in `Pipelines::create` derives the attribute table from.
2349#[repr(C)]
2350#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2351struct RectInstance {
2352    xywh: [f32; 4],
2353    radii: [f32; 4],
2354    color0: [f32; 4],
2355    color1: [f32; 4],
2356    fwd_mat: [f32; 4],
2357    grad: [f32; 4],
2358    bits: u32,
2359    _pad: [f32; 3],
2360}
2361
2362#[repr(C)]
2363#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2364struct BorderInstance {
2365    xywh: [f32; 4],
2366    radii: [f32; 4],
2367    color0: [f32; 4],
2368    color1: [f32; 4],
2369    fwd_mat: [f32; 4],
2370    grad: [f32; 4],
2371    bits: u32,
2372    stroke: f32,
2373    _pad: [f32; 2],
2374}
2375
2376#[repr(C)]
2377#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2378struct EllipseInstance {
2379    xywh: [f32; 4],
2380    color0: [f32; 4],
2381    color1: [f32; 4],
2382    fwd_mat: [f32; 4],
2383    grad: [f32; 4],
2384    bits: u32,
2385    _pad: [f32; 1],
2386}
2387
2388#[repr(C)]
2389#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2390struct EllipseBorderInstance {
2391    xywh: [f32; 4],
2392    color0: [f32; 4],
2393    color1: [f32; 4],
2394    fwd_mat: [f32; 4],
2395    grad: [f32; 4],
2396    bits: u32,
2397    stroke: f32,
2398    pad: f32,
2399    _pad: f32,
2400}
2401
2402#[repr(C)]
2403#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2404struct ArcInstance {
2405    xywh: [f32; 4],
2406    color0: [f32; 4],
2407    color1: [f32; 4],
2408    fwd_mat: [f32; 4],
2409    grad: [f32; 4],
2410    bits: u32,
2411    start_angle: f32,
2412    sweep_angle: f32,
2413    stroke: f32,
2414    pad: f32,
2415    _pad: f32,
2416}
2417
2418#[repr(C)]
2419#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2420struct GlyphInstance {
2421    xywh: [f32; 4],
2422    uv: [f32; 4],
2423    color: [f32; 4],
2424    fwd_mat: [f32; 4],
2425}
2426
2427#[repr(C)]
2428#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2429struct BlurInstance {
2430    xywh: [f32; 4],
2431    uv: [f32; 4],
2432    color: [f32; 4],
2433    sigma_px: [f32; 2],
2434    fwd_mat: [f32; 4],
2435    axis: u32,
2436    edge_mode: u32,
2437    /// Corner radii, in source texels, shaping the blurred silhouette. All zero
2438    /// leaves it unshaped. Read only by the source-reading axis; the axis that
2439    /// samples the scratch must leave this zero.
2440    shape: [f32; 4],
2441}
2442
2443/// Projective layer-composite instance: the four layer-rect corners projected
2444/// to NDC (`c0..c3`, counter-clockwise from top-left) with their homogeneous
2445/// `w`, the layer-texture uv bounds, and a group alpha. Matches
2446/// `projective_layer.wgsl` (offsets: c0@0 c1@8 c2@16 c3@24 uv@32 w@48
2447/// alpha@64; stride 80).
2448#[repr(C)]
2449#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2450struct ProjectiveInstance {
2451    c0: [f32; 2],
2452    c1: [f32; 2],
2453    c2: [f32; 2],
2454    c3: [f32; 2],
2455    uv: [f32; 4],
2456    w: [f32; 4],
2457    alpha: f32,
2458    _pad: [f32; 3],
2459}
2460
2461/// CPU-computed Y′CbCr -> R′G′B′ transform uploaded as a uniform buffer.
2462/// Layout matches the WGSL `YuvTransform` struct (5 × vec4<f32>).
2463#[repr(C)]
2464#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2465struct YuvTransformRaw {
2466    row0: [f32; 4],
2467    row1: [f32; 4],
2468    row2: [f32; 4],
2469    b: [f32; 4],
2470    transfer: [f32; 4],
2471}
2472
2473fn make_yuv_transform_raw(color_info: ColorInfo, pixel_format: PixelFormat) -> YuvTransformRaw {
2474    let yuv = color_info.to_yuv_transform();
2475    let sample_scale = match pixel_format {
2476        PixelFormat::P010 => 65535.0 / (64.0 * 1020.0),
2477        _ => 1.0,
2478    };
2479    let transfer_mode = match color_info.transfer {
2480        repose_core::color::Transfer::Srgb => 0.0,
2481        repose_core::color::Transfer::Bt709 => 1.0,
2482        repose_core::color::Transfer::Linear => 2.0,
2483        repose_core::color::Transfer::Pq => 3.0,
2484        repose_core::color::Transfer::Hlg => 4.0,
2485    };
2486    YuvTransformRaw {
2487        row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2488        row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2489        row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2490        b: [yuv.b[0], yuv.b[1], yuv.b[2], sample_scale],
2491        transfer: [transfer_mode, 0.0, 0.0, 0.0],
2492    }
2493}
2494
2495#[repr(C)]
2496#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2497struct Nv12Instance {
2498    xywh: [f32; 4],
2499    uv: [f32; 4],
2500    color: [f32; 4],
2501    uv_x_offset: f32,
2502    uv_y_offset: f32,
2503    fwd_mat: [f32; 4],
2504}
2505
2506#[repr(C)]
2507#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2508struct ClipInstance {
2509    xywh: [f32; 4],
2510    radii: [f32; 4],
2511    fwd_mat: [f32; 4],
2512}
2513
2514/// Backdrop-blend composite instance: a `GlyphInstance`-shaped quad plus
2515/// the `BlendMode` discriminant consumed by `blend_layer.wgsl`.
2516#[repr(C)]
2517#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2518struct BlendInstance {
2519    xywh: [f32; 4],
2520    uv: [f32; 4],
2521    color: [f32; 4],
2522    fwd_mat: [f32; 4],
2523    mode: u32,
2524    _pad: [f32; 3],
2525}
2526
2527#[repr(C)]
2528#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2529struct MeshVertex {
2530    pos: [f32; 2],
2531    color: [f32; 4],
2532    uv: [f32; 2],
2533}
2534
2535#[derive(Clone)]
2536struct MeshGpu {
2537    vertices: wgpu::Buffer,
2538    indices: wgpu::Buffer,
2539    vertex_count: u32,
2540    index_count: u32,
2541    bytes: u64,
2542}
2543
2544#[derive(Clone, Copy, PartialEq, Eq, Hash)]
2545struct MeshCacheKey {
2546    source: usize,
2547    index: usize,
2548    kind: u8,
2549    vertex_len: usize,
2550    index_len: usize,
2551}
2552
2553#[derive(Clone)]
2554enum MeshOwner {
2555    Object(Arc<repose_core::VectorMeshData>),
2556    Overlay(Arc<[repose_core::VectorMeshData]>),
2557}
2558
2559struct MeshCacheEntry {
2560    owner: MeshOwner,
2561    gpu: MeshGpu,
2562    last_used_frame: u64,
2563    last_touch_frame: u64,
2564}
2565
2566#[derive(Clone, PartialEq, Eq, Hash)]
2567struct SlugDrawKey {
2568    cache_key: repose_text::CacheKey,
2569    fill: bool,
2570    /// The stroke parameters themselves, not a digest of them: a hash
2571    /// collision here would serve another stroke's tessellation.
2572    stroke: Option<slug::StrokeTessKey>,
2573    transform: [u32; 11],
2574    origin: [u32; 2],
2575    px_bits: u32,
2576    target: [u32; 2],
2577    color: [u32; 4],
2578}
2579
2580struct SlugDrawCacheEntry {
2581    vertices: Arc<[slug::TessVertex]>,
2582    bytes: u64,
2583    last_used_frame: u64,
2584    last_touch_frame: u64,
2585}
2586
2587/// Vector outline for one glyph, cached so repeated text at the same size does
2588/// not re-run outline extraction. `commands` is `None` for glyphs the extractor
2589/// rejects; that negative result is cached too, so a bad glyph is not retried.
2590struct GlyphOutlineEntry {
2591    commands: Option<Arc<[repose_text::Command]>>,
2592    last_touch: u64,
2593}
2594
2595/// Drop least-recently-used atlas entries until the live set fits `target_area`
2596/// pixels, skipping glyphs batched into the frame in flight. Never empties the
2597/// atlas, so a repack always has something left to pack around. Returns false
2598/// when nothing is evictable.
2599fn evict_atlas_lru(
2600    map: &mut HashMap<repose_text::CacheKey, GlyphInfo>,
2601    used_area: &mut u64,
2602    frame_index: u64,
2603    target_area: u64,
2604) -> bool {
2605    let mut victims: Vec<(u64, repose_text::CacheKey)> = map
2606        .iter()
2607        .filter(|(_, info)| info.last_used_frame != frame_index)
2608        .map(|(key, info)| (info.last_touch, key.clone()))
2609        .collect();
2610    if victims.is_empty() {
2611        return false;
2612    }
2613    // Ties are equally cold, so their relative order does not matter.
2614    victims.sort_unstable_by_key(|victim| victim.0);
2615    let mut evicted = false;
2616    for (_, key) in victims {
2617        if *used_area <= target_area || map.len() <= 1 {
2618            break;
2619        }
2620        if let Some(info) = map.remove(&key) {
2621            *used_area = used_area.saturating_sub(glyph_cell_area(&info));
2622            evicted = true;
2623        }
2624    }
2625    evicted
2626}
2627
2628/// Pixels a packed cell occupies. `w`/`h` are exact small integers by the time
2629/// they reach `GlyphInfo`, so the round trip through `f32` is lossless.
2630fn glyph_cell_area(info: &GlyphInfo) -> u64 {
2631    (info.w as u64).saturating_mul(info.h as u64)
2632}
2633
2634/// Remember that a glyph had no room, so it is skipped instead of re-rasterized
2635/// every frame until the next repack. Capped like the failure sets; dropping
2636/// the oldest entries only costs an earlier retry.
2637fn defer_atlas_glyph(deferred: &mut HashSet<repose_text::CacheKey>, key: repose_text::CacheKey) {
2638    if deferred.len() >= MAX_GLYPH_ATLAS_ENTRIES {
2639        deferred.clear();
2640    }
2641    deferred.insert(key);
2642}
2643
2644/// What one slug draw cache entry really costs: the vertex array plus the key,
2645/// the entry record and the map slot holding it. Charging only the array makes
2646/// the byte budget silently optimistic.
2647fn slug_draw_cache_entry_bytes(vertices: usize) -> u64 {
2648    let per_entry = (std::mem::size_of::<SlugDrawKey>()
2649        + std::mem::size_of::<SlugDrawCacheEntry>()
2650        + std::mem::size_of::<(SlugDrawKey, SlugDrawCacheEntry)>()) as u64;
2651    u64::try_from(vertices)
2652        .ok()
2653        .and_then(|count| count.checked_mul(std::mem::size_of::<slug::TessVertex>() as u64))
2654        .and_then(|payload| payload.checked_add(per_entry))
2655        .unwrap_or(u64::MAX)
2656}
2657
2658/// Per-draw vector mesh uniform. Field order and every member width are
2659/// load-bearing: the WGSL `MeshUniform` in `shaders/mesh.wgsl` must match it
2660/// member for member, and uniform address space forces 16-byte alignment, so
2661/// the two-point gradient endpoints are padded out to `vec4`s and the stop
2662/// table is one `vec4` per colour plus four offsets per `vec4` (never a bare
2663/// `array<f32>`). `paint.z` carries the live stop count (0..=8).
2664#[repr(C)]
2665#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2666struct MeshUniform {
2667    m0: [f32; 4],
2668    m1: [f32; 4],
2669    paint: [u32; 4],
2670    color0: [f32; 4],
2671    color1: [f32; 4],
2672    grad_start: [f32; 2],
2673    _p3: [f32; 2],
2674    grad_end: [f32; 2],
2675    _p4: [f32; 2],
2676    stop_colors: [[f32; 4]; MAX_GRADIENT_STOPS],
2677    stop_offsets: [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2678}
2679
2680/// Bytes `MeshUniform` occupies. `tests::mesh_uniform_matches_the_wgsl_member_layout`
2681/// parses `shaders/mesh.wgsl` and checks every member offset and the total
2682/// size against the struct, so the two halves cannot drift apart.
2683const MESH_UNIFORM_BYTES: usize = 272;
2684const _: () = assert!(std::mem::size_of::<MeshUniform>() == MESH_UNIFORM_BYTES);
2685
2686const MESH_UNIFORM_CAP: u64 = 4 * 1024 * 1024;
2687const MAX_RETAINED_IMAGE_BYTES: u64 = 256 * 1024 * 1024;
2688const MAX_RETAINED_IMAGES: usize = 512;
2689const MAX_BLEND_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
2690const MAX_BLEND_SNAPSHOT_POOL_ENTRIES: usize = 64;
2691const MAX_GLYPH_ATLAS_ENTRIES: usize = 32768;
2692/// Hard ceiling on atlas edge length. Past this the atlas is compacted instead
2693/// of grown, dropping least-recently-used glyphs.
2694const MAX_GLYPH_ATLAS_SIZE: u32 = 4096;
2695/// Fraction of the live set a compaction keeps. Trimming to a fraction of the
2696/// current area (rather than an absolute budget) guarantees a repack always
2697/// reclaims shelf space, even when the atlas is only a few large glyphs deep.
2698const ATLAS_COMPACT_KEEP_NUM: u64 = 3;
2699const ATLAS_COMPACT_KEEP_DEN: u64 = 4;
2700/// Minimum frames between compactions. A working set that genuinely exceeds
2701/// atlas capacity then costs a few missing glyphs per interval instead of
2702/// re-uploading the whole atlas every frame.
2703const ATLAS_COMPACT_MIN_FRAME_INTERVAL: u64 = 30;
2704const MAX_GPU_RESOURCE_BYTES: u64 = 1024 * 1024 * 1024;
2705const MAX_UPLOAD_RING_BYTES: u64 = 64 * 1024 * 1024;
2706const MAX_MESH_CACHE_BYTES: u64 = 256 * 1024 * 1024;
2707const MAX_MESH_CACHE_ENTRIES: usize = 512;
2708const MAX_SLUG_DRAW_CACHE_ENTRIES: usize = 2048;
2709const MAX_SLUG_DRAW_CACHE_BYTES: u64 = 32 * 1024 * 1024;
2710const MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES: usize = 256;
2711const MAX_GLYPH_OUTLINE_CACHE_ENTRIES: usize = 4096;
2712const MAX_IMAGE_DECODE_BYTES: u64 = 64 * 1024 * 1024;
2713const MAX_CALLBACK_SCOPES: usize = 256;
2714const NV12_FOURCC: u32 = 0x3231_564e;
2715const P010_FOURCC: u32 = 0x3031_3050;
2716
2717fn canonical_fourcc(format: PixelFormat) -> u32 {
2718    match format {
2719        PixelFormat::P010 => P010_FOURCC,
2720        _ => NV12_FOURCC,
2721    }
2722}
2723
2724impl MeshUniform {
2725    fn identity() -> Self {
2726        Self {
2727            m0: [1.0, 0.0, 0.0, 0.0],
2728            m1: [0.0, 1.0, 0.0, 0.0],
2729            paint: [0; 4],
2730            color0: [0.0; 4],
2731            color1: [0.0; 4],
2732            grad_start: [0.0; 2],
2733            _p3: [0.0; 2],
2734            grad_end: [0.0; 2],
2735            _p4: [0.0; 2],
2736            stop_colors: [[0.0; 4]; MAX_GRADIENT_STOPS],
2737            stop_offsets: [[0.0; 4]; MAX_GRADIENT_STOPS / 4],
2738        }
2739    }
2740}
2741
2742/// Colours, packed offsets and the live count, ready for `MeshUniform`.
2743type PackedStops = (
2744    [[f32; 4]; MAX_GRADIENT_STOPS],
2745    [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2746    u32,
2747);
2748
2749/// Packs a stop table for the uniform: one `vec4` per colour, four offsets per
2750/// `vec4`, plus the live count (0..=8). Offsets are clamped to `0..=1` and
2751/// forced non-decreasing so the shader can scan them in order without sorting;
2752/// a NaN offset folds to the previous stop's (`f32::max` drops NaN). Order is
2753/// otherwise preserved, so repeated offsets stay a hard stop at that position.
2754fn pack_gradient_stops(stops: &GradientStops) -> PackedStops {
2755    let mut stop_colors = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
2756    let mut flat = [0.0f32; MAX_GRADIENT_STOPS];
2757    let mut count = 0u32;
2758    let mut prev = 0.0f32;
2759    for ((color, offset), stop) in stop_colors
2760        .iter_mut()
2761        .zip(flat.iter_mut())
2762        .zip(stops.as_slice())
2763    {
2764        let clamped = stop.offset.clamp(0.0, 1.0).max(prev);
2765        prev = clamped;
2766        *color = stop.color.to_linear();
2767        *offset = clamped;
2768        count += 1;
2769    }
2770    let mut stop_offsets = [[0.0f32; 4]; MAX_GRADIENT_STOPS / 4];
2771    for (dst, src) in stop_offsets.iter_mut().zip(flat.as_chunks::<4>().0) {
2772        *dst = *src;
2773    }
2774    (stop_colors, stop_offsets, count)
2775}
2776
2777fn mesh_uniform_from_paint(affine: [f32; 6], paint: &repose_core::PaintDesc) -> MeshUniform {
2778    let no_stops = GradientStops::default();
2779    let (paint_type, paint_kind, stops, color0, color1, grad_start, grad_end) = match paint {
2780        repose_core::PaintDesc::Solid => {
2781            (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2])
2782        }
2783        repose_core::PaintDesc::Linear {
2784            start,
2785            end,
2786            start_color,
2787            end_color,
2788        } => (
2789            1u32,
2790            0u32,
2791            no_stops,
2792            start_color.to_linear(),
2793            end_color.to_linear(),
2794            [start.x, start.y],
2795            [end.x, end.y],
2796        ),
2797        repose_core::PaintDesc::Radial {
2798            center,
2799            radius,
2800            start_color,
2801            end_color,
2802        } => (
2803            1u32,
2804            1u32,
2805            no_stops,
2806            start_color.to_linear(),
2807            end_color.to_linear(),
2808            [center.x, center.y],
2809            [radius.max(0.0), 0.0],
2810        ),
2811        repose_core::PaintDesc::Sweep {
2812            center,
2813            start_color,
2814            end_color,
2815        } => (
2816            1u32,
2817            2u32,
2818            no_stops,
2819            start_color.to_linear(),
2820            end_color.to_linear(),
2821            [center.x, center.y],
2822            [0.0, 0.0],
2823        ),
2824        repose_core::PaintDesc::LinearStops { start, end, stops } => (
2825            1u32,
2826            3u32,
2827            *stops,
2828            [0.0; 4],
2829            [0.0; 4],
2830            [start.x, start.y],
2831            [end.x, end.y],
2832        ),
2833        repose_core::PaintDesc::RadialStops {
2834            center,
2835            radius,
2836            stops,
2837        } => (
2838            1u32,
2839            4u32,
2840            *stops,
2841            [0.0; 4],
2842            [0.0; 4],
2843            [center.x, center.y],
2844            [radius.max(0.0), 0.0],
2845        ),
2846        _ => (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
2847    };
2848    let (stop_colors, stop_offsets, count) = pack_gradient_stops(&stops);
2849    MeshUniform {
2850        m0: [affine[0], affine[1], affine[2], 0.0],
2851        m1: [affine[3], affine[4], affine[5], 0.0],
2852        paint: [paint_type, paint_kind, count, 0],
2853        color0,
2854        color1,
2855        grad_start,
2856        _p3: [0.0; 2],
2857        grad_end,
2858        _p4: [0.0; 2],
2859        stop_colors,
2860        stop_offsets,
2861    }
2862}
2863
2864fn combine_mesh_affine(current: &Transform, mesh: [f32; 6]) -> [f32; 6] {
2865    let cm = current.linear();
2866    let (cm00, cm01, cm10, cm11) = (cm[0], cm[1], cm[2], cm[3]);
2867    let mm00 = mesh[0];
2868    let mm01 = mesh[1];
2869    let mm10 = mesh[2];
2870    let mm11 = mesh[3];
2871    let mtx = mesh[4];
2872    let mty = mesh[5];
2873    let r00 = cm00 * mm00 + cm01 * mm10;
2874    let r01 = cm00 * mm01 + cm01 * mm11;
2875    let r10 = cm10 * mm00 + cm11 * mm10;
2876    let r11 = cm10 * mm01 + cm11 * mm11;
2877    let tx = cm00 * mtx + cm01 * mty + current.translate_x;
2878    let ty = cm10 * mtx + cm11 * mty + current.translate_y;
2879    // Canonical slot order consumed by `MeshUniform`/shader and `mesh_aabb`:
2880    // [A, B, tx, C, D, ty] where world = [[A,B],[C,D]] * local + (tx, ty).
2881    [r00, r01, tx, r10, r11, ty]
2882}
2883
2884fn mesh_aabb(mesh: &repose_core::VectorMeshData, affine: [f32; 6]) -> repose_core::Rect {
2885    let mut min_x = f32::INFINITY;
2886    let mut min_y = f32::INFINITY;
2887    let mut max_x = f32::NEG_INFINITY;
2888    let mut max_y = f32::NEG_INFINITY;
2889    for v in mesh.vertices.iter() {
2890        let x = affine[0] * v.pos[0] + affine[1] * v.pos[1] + affine[2];
2891        let y = affine[3] * v.pos[0] + affine[4] * v.pos[1] + affine[5];
2892        min_x = min_x.min(x);
2893        min_y = min_y.min(y);
2894        max_x = max_x.max(x);
2895        max_y = max_y.max(y);
2896    }
2897    let w = (max_x - min_x).max(0.0);
2898    let h = (max_y - min_y).max(0.0);
2899    if !min_x.is_finite() || !min_y.is_finite() {
2900        return repose_core::Rect {
2901            x: 0.0,
2902            y: 0.0,
2903            w: 0.0,
2904            h: 0.0,
2905        };
2906    }
2907    repose_core::Rect {
2908        x: min_x,
2909        y: min_y,
2910        w,
2911        h,
2912    }
2913}
2914
2915fn quantize_glyph_px(px: f32) -> f32 {
2916    if !px.is_finite() || px <= 0.0 {
2917        return 1.0;
2918    }
2919    ((px * 4.0).round() / 4.0).max(0.25)
2920}
2921
2922fn swash_to_a8_coverage(content: repose_text::SwashContent, data: &[u8]) -> Option<Vec<u8>> {
2923    match content {
2924        repose_text::SwashContent::Mask => Some(data.to_vec()),
2925        repose_text::SwashContent::SubpixelMask => {
2926            let mut out = Vec::with_capacity(data.len() / 4);
2927            for px in data.as_chunks::<4>().0 {
2928                let r = px[0];
2929                let g = px[1];
2930                let b = px[2];
2931                out.push(r.max(g).max(b));
2932            }
2933            Some(out)
2934        }
2935        repose_text::SwashContent::Color => None,
2936    }
2937}
2938
2939impl WgpuSceneRenderer {
2940    /// Construct a renderer for `output_format` with `msaa_samples` samples.
2941    ///
2942    /// Validates what is checkable without an adapter: `wgpu` only accepts
2943    /// sample counts of 1, 2, 4, 8 or 16, and an unrenderable or
2944    /// unblendable output format fails later as a validation error rather
2945    /// than here. Prefer
2946    /// [`try_from_device_with_adapter`](Self::try_from_device_with_adapter)
2947    /// whenever an adapter is available - it also checks the format's
2948    /// render-attachment, blend and multisample-resolve support and picks a
2949    /// supported sample count.
2950    pub fn from_device(
2951        device: wgpu::Device,
2952        queue: wgpu::Queue,
2953        output_format: wgpu::TextureFormat,
2954        msaa_samples: u32,
2955    ) -> anyhow::Result<Self> {
2956        validate_sample_count("msaa_samples", msaa_samples)?;
2957        Self::from_device_with_working_space_msaa(
2958            device,
2959            queue,
2960            output_format,
2961            msaa_samples,
2962            msaa_samples,
2963        )
2964    }
2965
2966    /// Validated constructor with an adapter: rejects an output format that
2967    /// cannot back the UI pipelines and clamps `requested_msaa` to the
2968    /// largest supported count at or below it.
2969    pub fn try_from_device_with_adapter(
2970        adapter: &wgpu::Adapter,
2971        device: wgpu::Device,
2972        queue: wgpu::Queue,
2973        output_format: wgpu::TextureFormat,
2974        requested_msaa: u32,
2975    ) -> anyhow::Result<Self> {
2976        let msaa = pick_surface_msaa(adapter, output_format, requested_msaa);
2977        validate_render_target_format(adapter, output_format, msaa)?;
2978        let working_space_msaa =
2979            pick_surface_msaa_for_mode(adapter, wgpu::TextureFormat::Rgba16Float, msaa, true);
2980        Self::from_device_with_working_space_msaa(
2981            device,
2982            queue,
2983            output_format,
2984            msaa,
2985            working_space_msaa,
2986        )
2987    }
2988
2989    /// Point the renderer at a surface whose texture format differs from
2990    /// [`Self::output_format`], as a canvas configured with `viewFormats` does.
2991    /// Call this before the first frame.
2992    pub fn with_surface_format(
2993        mut self,
2994        surface_format: wgpu::TextureFormat,
2995    ) -> anyhow::Result<Self> {
2996        if surface_format != self.output_format
2997            && surface_format.add_srgb_suffix() != self.output_format
2998        {
2999            anyhow::bail!(
3000                "surface format {surface_format:?} cannot be reinterpreted as {:?}",
3001                self.output_format
3002            );
3003        }
3004        if self.surface_format != surface_format {
3005            self.surface_format = surface_format;
3006            self.recreate_msaa_and_depth_stencil();
3007        }
3008        Ok(self)
3009    }
3010
3011    pub fn from_device_with_working_space_msaa(
3012        device: wgpu::Device,
3013        queue: wgpu::Queue,
3014        output_format: wgpu::TextureFormat,
3015        msaa_samples: u32,
3016        working_space_msaa_samples: u32,
3017    ) -> anyhow::Result<Self> {
3018        validate_sample_count("msaa_samples", msaa_samples)?;
3019        validate_sample_count("working_space_msaa_samples", working_space_msaa_samples)?;
3020        let msaa_samples = msaa_samples.max(1);
3021        let working_space_msaa_samples = working_space_msaa_samples.max(1);
3022        let mesh_uniform_size = std::mem::size_of::<MeshUniform>() as u64;
3023        let base_mesh_alignment =
3024            u64::from(device.limits().min_uniform_buffer_offset_alignment).max(4);
3025        let mesh_uniform_alignment = mesh_uniform_stride(mesh_uniform_size, base_mesh_alignment);
3026        let mesh_uniform_cap = align_up(
3027            MESH_UNIFORM_CAP
3028                .min(device.limits().max_buffer_size)
3029                .max(mesh_uniform_alignment),
3030            base_mesh_alignment,
3031        )
3032        .unwrap_or(mesh_uniform_alignment);
3033        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3034            label: Some("globals layout"),
3035            entries: &[wgpu::BindGroupLayoutEntry {
3036                binding: 0,
3037                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3038                ty: wgpu::BindingType::Buffer {
3039                    ty: wgpu::BufferBindingType::Uniform,
3040                    has_dynamic_offset: false,
3041                    min_binding_size: None,
3042                },
3043                count: None,
3044            }],
3045        });
3046
3047        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
3048            label: Some("globals buf"),
3049            size: std::mem::size_of::<Globals>() as u64,
3050            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3051            mapped_at_creation: false,
3052        });
3053
3054        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3055            label: Some("globals bind"),
3056            layout: &globals_layout,
3057            entries: &[wgpu::BindGroupEntry {
3058                binding: 0,
3059                resource: globals_buf.as_entire_binding(),
3060            }],
3061        });
3062        let globals_staging_cap =
3063            (64 * std::mem::size_of::<Globals>() as u64).max(std::mem::size_of::<Globals>() as u64);
3064        let globals_staging = device.create_buffer(&wgpu::BufferDescriptor {
3065            label: Some("globals staging"),
3066            size: globals_staging_cap,
3067            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
3068            mapped_at_creation: false,
3069        });
3070
3071        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
3072
3073        let stencil_for_content = wgpu::DepthStencilState {
3074            format: ds_format,
3075            depth_write_enabled: Some(false),
3076            depth_compare: Some(wgpu::CompareFunction::Always),
3077            stencil: wgpu::StencilState {
3078                front: wgpu::StencilFaceState {
3079                    // Equal (not LessEqual): inverse (`Difference`) vector
3080                    // masks work by keeping the depth while incrementing the
3081                    // masked pixels, so content must test exact equality.
3082                    // Outcomes match LessEqual everywhere except pre-existing
3083                    // stencil leaks, which now fail visibly instead of
3084                    // drawing through (unbalanced clips already warn).
3085                    compare: wgpu::CompareFunction::Equal,
3086                    fail_op: wgpu::StencilOperation::Keep,
3087                    depth_fail_op: wgpu::StencilOperation::Keep,
3088                    pass_op: wgpu::StencilOperation::Keep,
3089                },
3090                back: wgpu::StencilFaceState {
3091                    compare: wgpu::CompareFunction::Equal,
3092                    fail_op: wgpu::StencilOperation::Keep,
3093                    depth_fail_op: wgpu::StencilOperation::Keep,
3094                    pass_op: wgpu::StencilOperation::Keep,
3095                },
3096                read_mask: 0xFF,
3097                write_mask: 0x00,
3098            },
3099            bias: wgpu::DepthBiasState::default(),
3100        };
3101
3102        let stencil_for_clip_inc = wgpu::DepthStencilState {
3103            format: ds_format,
3104            depth_write_enabled: Some(false),
3105            depth_compare: Some(wgpu::CompareFunction::Always),
3106            stencil: wgpu::StencilState {
3107                front: wgpu::StencilFaceState {
3108                    compare: wgpu::CompareFunction::Always,
3109                    fail_op: wgpu::StencilOperation::Keep,
3110                    depth_fail_op: wgpu::StencilOperation::Keep,
3111                    pass_op: wgpu::StencilOperation::IncrementClamp,
3112                },
3113                back: wgpu::StencilFaceState {
3114                    compare: wgpu::CompareFunction::Always,
3115                    fail_op: wgpu::StencilOperation::Keep,
3116                    depth_fail_op: wgpu::StencilOperation::Keep,
3117                    pass_op: wgpu::StencilOperation::IncrementClamp,
3118                },
3119                read_mask: 0xFF,
3120                write_mask: 0xFF,
3121            },
3122            bias: wgpu::DepthBiasState::default(),
3123        };
3124
3125        let stencil_for_clip_dec = wgpu::DepthStencilState {
3126            format: ds_format,
3127            depth_write_enabled: Some(false),
3128            depth_compare: Some(wgpu::CompareFunction::Always),
3129            stencil: wgpu::StencilState {
3130                front: wgpu::StencilFaceState {
3131                    compare: wgpu::CompareFunction::Always,
3132                    fail_op: wgpu::StencilOperation::Keep,
3133                    depth_fail_op: wgpu::StencilOperation::Keep,
3134                    pass_op: wgpu::StencilOperation::DecrementClamp,
3135                },
3136                back: wgpu::StencilFaceState {
3137                    compare: wgpu::CompareFunction::Always,
3138                    fail_op: wgpu::StencilOperation::Keep,
3139                    depth_fail_op: wgpu::StencilOperation::Keep,
3140                    pass_op: wgpu::StencilOperation::DecrementClamp,
3141                },
3142                read_mask: 0xFF,
3143                write_mask: 0xFF,
3144            },
3145            bias: wgpu::DepthBiasState::default(),
3146        };
3147
3148        let _multisample_state = wgpu::MultisampleState {
3149            count: msaa_samples,
3150            mask: !0,
3151            alpha_to_coverage_enabled: false,
3152        };
3153
3154        // PIPELINES
3155
3156        // Single shared sampler for images/text
3157        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3158            label: Some("image/text sampler"),
3159            address_mode_u: wgpu::AddressMode::ClampToEdge,
3160            address_mode_v: wgpu::AddressMode::ClampToEdge,
3161            mag_filter: wgpu::FilterMode::Linear,
3162            min_filter: wgpu::FilterMode::Linear,
3163            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3164            ..Default::default()
3165        });
3166        let image_sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3167            label: Some("image nearest sampler"),
3168            address_mode_u: wgpu::AddressMode::ClampToEdge,
3169            address_mode_v: wgpu::AddressMode::ClampToEdge,
3170            mag_filter: wgpu::FilterMode::Nearest,
3171            min_filter: wgpu::FilterMode::Nearest,
3172            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3173            ..Default::default()
3174        });
3175
3176        // `ImageFit::Tile` emits UVs past 1.0 and relies on wrapping, so it
3177        // needs its own samplers rather than the clamped pair above.
3178        let image_sampler_repeat = device.create_sampler(&wgpu::SamplerDescriptor {
3179            label: Some("image repeat sampler"),
3180            address_mode_u: wgpu::AddressMode::Repeat,
3181            address_mode_v: wgpu::AddressMode::Repeat,
3182            mag_filter: wgpu::FilterMode::Linear,
3183            min_filter: wgpu::FilterMode::Linear,
3184            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3185            ..Default::default()
3186        });
3187        let image_sampler_repeat_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3188            label: Some("image repeat nearest sampler"),
3189            address_mode_u: wgpu::AddressMode::Repeat,
3190            address_mode_v: wgpu::AddressMode::Repeat,
3191            mag_filter: wgpu::FilterMode::Nearest,
3192            min_filter: wgpu::FilterMode::Nearest,
3193            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3194            ..Default::default()
3195        });
3196
3197        // linear filtering only blurs them; nearest keeps the blit crisp.
3198        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3199            label: Some("layer nearest sampler"),
3200            address_mode_u: wgpu::AddressMode::ClampToEdge,
3201            address_mode_v: wgpu::AddressMode::ClampToEdge,
3202            mag_filter: wgpu::FilterMode::Nearest,
3203            min_filter: wgpu::FilterMode::Nearest,
3204            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3205            ..Default::default()
3206        });
3207
3208        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
3209        // the sharp 1:1 layer composite.
3210        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
3211            label: Some("layer linear sampler"),
3212            address_mode_u: wgpu::AddressMode::ClampToEdge,
3213            address_mode_v: wgpu::AddressMode::ClampToEdge,
3214            mag_filter: wgpu::FilterMode::Linear,
3215            min_filter: wgpu::FilterMode::Linear,
3216            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3217            ..Default::default()
3218        });
3219
3220        // Layout for Text / RGBA Images (Texture + Sampler)
3221        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3222            label: Some("text/rgba bind layout"),
3223            entries: &[
3224                wgpu::BindGroupLayoutEntry {
3225                    binding: 0,
3226                    visibility: wgpu::ShaderStages::FRAGMENT,
3227                    ty: wgpu::BindingType::Texture {
3228                        multisampled: false,
3229                        view_dimension: wgpu::TextureViewDimension::D2,
3230                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3231                    },
3232                    count: None,
3233                },
3234                wgpu::BindGroupLayoutEntry {
3235                    binding: 1,
3236                    visibility: wgpu::ShaderStages::FRAGMENT,
3237                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3238                    count: None,
3239                },
3240            ],
3241        });
3242        // We reuse this for RGBA images for simplicity, or create a distinct one
3243        let image_bind_layout_rgba = text_bind_layout.clone();
3244
3245        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
3246        let image_bind_layout_nv12 =
3247            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3248                label: Some("image bind layout nv12"),
3249                entries: &[
3250                    // Y plane
3251                    wgpu::BindGroupLayoutEntry {
3252                        binding: 0,
3253                        visibility: wgpu::ShaderStages::FRAGMENT,
3254                        ty: wgpu::BindingType::Texture {
3255                            multisampled: false,
3256                            view_dimension: wgpu::TextureViewDimension::D2,
3257                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3258                        },
3259                        count: None,
3260                    },
3261                    // UV plane
3262                    wgpu::BindGroupLayoutEntry {
3263                        binding: 1,
3264                        visibility: wgpu::ShaderStages::FRAGMENT,
3265                        ty: wgpu::BindingType::Texture {
3266                            multisampled: false,
3267                            view_dimension: wgpu::TextureViewDimension::D2,
3268                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3269                        },
3270                        count: None,
3271                    },
3272                    // Sampler
3273                    wgpu::BindGroupLayoutEntry {
3274                        binding: 2,
3275                        visibility: wgpu::ShaderStages::FRAGMENT,
3276                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3277                        count: None,
3278                    },
3279                    // YUV transform uniform buffer
3280                    wgpu::BindGroupLayoutEntry {
3281                        binding: 3,
3282                        visibility: wgpu::ShaderStages::FRAGMENT,
3283                        ty: wgpu::BindingType::Buffer {
3284                            ty: wgpu::BufferBindingType::Uniform,
3285                            has_dynamic_offset: false,
3286                            min_binding_size: None,
3287                        },
3288                        count: None,
3289                    },
3290                ],
3291            });
3292
3293        // Clipping layout
3294        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3295            label: Some("clip pipeline layout"),
3296            bind_group_layouts: &[Some(&globals_layout)],
3297            immediate_size: 0,
3298        });
3299        let clip_vertex_layout = wgpu::VertexBufferLayout {
3300            array_stride: std::mem::size_of::<ClipInstance>() as u64,
3301            step_mode: wgpu::VertexStepMode::Instance,
3302            attributes: &[
3303                wgpu::VertexAttribute {
3304                    shader_location: 0,
3305                    offset: 0,
3306                    format: wgpu::VertexFormat::Float32x4,
3307                },
3308                wgpu::VertexAttribute {
3309                    shader_location: 1,
3310                    offset: 16,
3311                    format: wgpu::VertexFormat::Float32x4,
3312                },
3313                wgpu::VertexAttribute {
3314                    shader_location: 2,
3315                    offset: 32,
3316                    format: wgpu::VertexFormat::Float32x4,
3317                },
3318            ],
3319        };
3320        // Bind layout for per-draw vector mesh uniforms (dynamic offset).
3321        let mesh_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3322            label: Some("mesh uniform layout"),
3323            entries: &[wgpu::BindGroupLayoutEntry {
3324                binding: 0,
3325                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3326                ty: wgpu::BindingType::Buffer {
3327                    ty: wgpu::BufferBindingType::Uniform,
3328                    has_dynamic_offset: true,
3329                    min_binding_size: NonZeroU64::new(mesh_uniform_size),
3330                },
3331                count: None,
3332            }],
3333        });
3334        let mesh_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3335            label: Some("mesh uniform buffer"),
3336            size: mesh_uniform_cap,
3337            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3338            mapped_at_creation: false,
3339        });
3340        let mesh_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3341            label: Some("mesh uniform bind"),
3342            layout: &mesh_bind_layout,
3343            entries: &[wgpu::BindGroupEntry {
3344                binding: 0,
3345                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3346                    buffer: &mesh_uniform_buf,
3347                    offset: 0,
3348                    size: NonZeroU64::new(mesh_uniform_size),
3349                }),
3350            }],
3351        });
3352
3353        // Two sets of pipelines: one for the MSAA surface pass, one for layer
3354        // render-to-texture passes (sample_count = 1).
3355        let surface_pipes = Pipelines::create(
3356            &device,
3357            output_format,
3358            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 working_space_pipes = Pipelines::create(
3370            &device,
3371            wgpu::TextureFormat::Rgba16Float,
3372            working_space_msaa_samples,
3373            &globals_layout,
3374            &text_bind_layout,
3375            &image_bind_layout_nv12,
3376            &clip_pipeline_layout,
3377            &stencil_for_content,
3378            &stencil_for_clip_inc,
3379            &stencil_for_clip_dec,
3380            &clip_vertex_layout,
3381            &mesh_bind_layout,
3382        );
3383        let layer_pipes = if msaa_samples == 1 {
3384            surface_pipes.clone()
3385        } else {
3386            Pipelines::create(
3387                &device,
3388                output_format,
3389                1,
3390                &globals_layout,
3391                &text_bind_layout,
3392                &image_bind_layout_nv12,
3393                &clip_pipeline_layout,
3394                &stencil_for_content,
3395                &stencil_for_clip_inc,
3396                &stencil_for_clip_dec,
3397                &clip_vertex_layout,
3398                &mesh_bind_layout,
3399            )
3400        };
3401        let working_space_layer_pipes = if working_space_msaa_samples == 1 {
3402            working_space_pipes.clone()
3403        } else {
3404            Pipelines::create(
3405                &device,
3406                wgpu::TextureFormat::Rgba16Float,
3407                1,
3408                &globals_layout,
3409                &text_bind_layout,
3410                &image_bind_layout_nv12,
3411                &clip_pipeline_layout,
3412                &stencil_for_content,
3413                &stencil_for_clip_inc,
3414                &stencil_for_clip_dec,
3415                &clip_vertex_layout,
3416                &mesh_bind_layout,
3417            )
3418        };
3419
3420        // Vector glyph rendering always available with tessellation+MSAA approach.
3421        let slug_enabled = true;
3422
3423        // Blur composite ring (for graphics-layer drop shadows)
3424        let blur_ring = UploadRing::new(
3425            &device,
3426            "blur ring",
3427            1024 * 1024,
3428            wgpu::BufferUsages::VERTEX
3429                | wgpu::BufferUsages::COPY_DST
3430                | wgpu::BufferUsages::COPY_SRC,
3431        );
3432
3433        // Atlases
3434        let atlas_mask = init_atlas_mask(&device);
3435        let atlas_color = init_atlas_color(&device);
3436        let atlas_mask_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3437            label: Some("atlas mask bind"),
3438            layout: &text_bind_layout,
3439            entries: &[
3440                wgpu::BindGroupEntry {
3441                    binding: 0,
3442                    resource: wgpu::BindingResource::TextureView(&atlas_mask.view),
3443                },
3444                wgpu::BindGroupEntry {
3445                    binding: 1,
3446                    resource: wgpu::BindingResource::Sampler(&atlas_mask.sampler),
3447                },
3448            ],
3449        });
3450        let atlas_color_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3451            label: Some("atlas color bind"),
3452            layout: &text_bind_layout,
3453            entries: &[
3454                wgpu::BindGroupEntry {
3455                    binding: 0,
3456                    resource: wgpu::BindingResource::TextureView(&atlas_color.view),
3457                },
3458                wgpu::BindGroupEntry {
3459                    binding: 1,
3460                    resource: wgpu::BindingResource::Sampler(&atlas_color.sampler),
3461                },
3462            ],
3463        });
3464
3465        // Upload rings
3466        let ring_rect = UploadRing::new(
3467            &device,
3468            "ring rect",
3469            1 << 20,
3470            wgpu::BufferUsages::VERTEX
3471                | wgpu::BufferUsages::COPY_DST
3472                | wgpu::BufferUsages::COPY_SRC,
3473        );
3474        let ring_border = UploadRing::new(
3475            &device,
3476            "ring border",
3477            1 << 20,
3478            wgpu::BufferUsages::VERTEX
3479                | wgpu::BufferUsages::COPY_DST
3480                | wgpu::BufferUsages::COPY_SRC,
3481        );
3482        let ring_ellipse = UploadRing::new(
3483            &device,
3484            "ring ellipse",
3485            1 << 20,
3486            wgpu::BufferUsages::VERTEX
3487                | wgpu::BufferUsages::COPY_DST
3488                | wgpu::BufferUsages::COPY_SRC,
3489        );
3490        let ring_ellipse_border = UploadRing::new(
3491            &device,
3492            "ring ellipse border",
3493            1 << 20,
3494            wgpu::BufferUsages::VERTEX
3495                | wgpu::BufferUsages::COPY_DST
3496                | wgpu::BufferUsages::COPY_SRC,
3497        );
3498        let ring_arc = UploadRing::new(
3499            &device,
3500            "ring arc",
3501            1 << 20,
3502            wgpu::BufferUsages::VERTEX
3503                | wgpu::BufferUsages::COPY_DST
3504                | wgpu::BufferUsages::COPY_SRC,
3505        );
3506        let ring_glyph_mask = UploadRing::new(
3507            &device,
3508            "ring glyph mask",
3509            1 << 20,
3510            wgpu::BufferUsages::VERTEX
3511                | wgpu::BufferUsages::COPY_DST
3512                | wgpu::BufferUsages::COPY_SRC,
3513        );
3514        let ring_glyph_color = UploadRing::new(
3515            &device,
3516            "ring glyph color",
3517            1 << 20,
3518            wgpu::BufferUsages::VERTEX
3519                | wgpu::BufferUsages::COPY_DST
3520                | wgpu::BufferUsages::COPY_SRC,
3521        );
3522        let ring_slug = UploadRing::new(
3523            &device,
3524            "ring slug",
3525            1 << 22,
3526            wgpu::BufferUsages::VERTEX
3527                | wgpu::BufferUsages::COPY_DST
3528                | wgpu::BufferUsages::COPY_SRC,
3529        );
3530        let ring_clip = UploadRing::new(
3531            &device,
3532            "ring clip",
3533            1 << 16,
3534            wgpu::BufferUsages::VERTEX
3535                | wgpu::BufferUsages::COPY_DST
3536                | wgpu::BufferUsages::COPY_SRC,
3537        );
3538        let blend_ring = UploadRing::new(
3539            &device,
3540            "ring blend",
3541            1 << 16,
3542            wgpu::BufferUsages::VERTEX
3543                | wgpu::BufferUsages::COPY_DST
3544                | wgpu::BufferUsages::COPY_SRC,
3545        );
3546        let ring_projective = UploadRing::new(
3547            &device,
3548            "ring projective",
3549            1 << 16,
3550            wgpu::BufferUsages::VERTEX
3551                | wgpu::BufferUsages::COPY_DST
3552                | wgpu::BufferUsages::COPY_SRC,
3553        );
3554        let ring_nv12 = UploadRing::new(
3555            &device,
3556            "ring nv12",
3557            1 << 20,
3558            wgpu::BufferUsages::VERTEX
3559                | wgpu::BufferUsages::COPY_DST
3560                | wgpu::BufferUsages::COPY_SRC,
3561        );
3562        // Placeholder textures
3563        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
3564            label: Some("temp ds"),
3565            size: wgpu::Extent3d {
3566                width: 1,
3567                height: 1,
3568                depth_or_array_layers: 1,
3569            },
3570            mip_level_count: 1,
3571            sample_count: 1,
3572            dimension: wgpu::TextureDimension::D2,
3573            format: wgpu::TextureFormat::Depth24PlusStencil8,
3574            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3575            view_formats: &[],
3576        });
3577        let depth_stencil_view =
3578            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
3579
3580        let mut renderer = WgpuSceneRenderer {
3581            device,
3582            queue,
3583            output_format,
3584            surface_format: output_format,
3585            output_width: 0,
3586            output_height: 0,
3587            pixels_per_point: 1.0,
3588
3589            surface_pipes,
3590            working_space_pipes,
3591            layer_pipes,
3592            working_space_layer_pipes,
3593
3594            rects: InstancedPipe::new(ring_rect),
3595            borders: InstancedPipe::new(ring_border),
3596            ellipses: InstancedPipe::new(ring_ellipse),
3597            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
3598            arcs: InstancedPipe::new(ring_arc),
3599            glyph_mask: InstancedPipe::new(ring_glyph_mask),
3600            glyph_color: InstancedPipe::new(ring_glyph_color),
3601
3602            text_bind_layout,
3603
3604            image_bind_layout_rgba,
3605            image_bind_layout_nv12,
3606            image_sampler,
3607            image_sampler_nearest,
3608            image_sampler_repeat,
3609            image_sampler_repeat_nearest,
3610            layer_sampler,
3611            layer_sampler_linear,
3612
3613            blur_ring,
3614
3615            slug_enabled,
3616            slug_ring: ring_slug,
3617            slug_cache: slug::GlyphSlugCache::new(),
3618            slug_draw_cache: HashMap::new(),
3619            slug_draw_cache_bytes_total: 0,
3620
3621            clip_ring: ring_clip,
3622
3623            nv12: InstancedPipe::new(ring_nv12),
3624
3625            mesh_cache: HashMap::new(),
3626            mesh_cache_bytes_total: 0,
3627            mesh_uniform_buf,
3628            mesh_bind,
3629            mesh_uniform_head: 0,
3630            mesh_uniform_alignment,
3631            mesh_uniform_cap,
3632
3633            projective_ring: ring_projective,
3634            blend_ring,
3635            flatten_layer_ids: Vec::new(),
3636            blur_scratch_ids: HashMap::new(),
3637            blur_scratch_next: 0,
3638            blend_snapshots: std::collections::HashMap::new(),
3639            blend_snapshot_pool: HashMap::new(),
3640            blend_copies: Vec::new(),
3641
3642            msaa_samples,
3643            working_space_msaa_samples,
3644            depth_stencil_tex,
3645            depth_stencil_view,
3646            msaa_tex: None,
3647            msaa_view: None,
3648            ws_msaa_tex: None,
3649            ws_msaa_view: None,
3650            surface_resolve_tex: None,
3651            surface_resolve_view: None,
3652            surface_resolve_bytes: 0,
3653            msaa_bytes: 0,
3654            ws_msaa_bytes: 0,
3655            depth_stencil_bytes: 0,
3656            working_space_bytes: 0,
3657            gpu_budget_bytes: MAX_GPU_RESOURCE_BYTES,
3658            globals_bind,
3659            globals_buf,
3660            globals_staging,
3661            globals_staging_cap,
3662
3663            atlas_mask,
3664            atlas_color,
3665            atlas_mask_bind,
3666            atlas_color_bind,
3667            atlas_generation: repose_text::font_generation(),
3668            atlas_mask_pending: AtlasPending::default(),
3669            atlas_color_pending: AtlasPending::default(),
3670            atlas_last_compact_frame: 0,
3671            atlas_mask_failures: HashSet::new(),
3672            atlas_color_failures: HashSet::new(),
3673            atlas_mask_deferred: HashSet::new(),
3674            atlas_color_deferred: HashSet::new(),
3675            glyph_outline_cache: HashMap::new(),
3676            legacy_shaped_text_cache: HashMap::new(),
3677
3678            next_image_handle: 1,
3679            images: HashMap::new(),
3680            retained: HashMap::new(),
3681            retained_bytes_total: 0,
3682
3683            next_coverage_handle: 1,
3684            coverages: HashMap::new(),
3685
3686            frame_index: 0,
3687            cache_touch_clock: 0,
3688            image_bytes_total: 0,
3689            image_evict_after_frames: 600,         // ~10s @ 60fps
3690            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
3691            layer_pool: HashMap::new(),
3692            producer_layer_ids: HashSet::new(),
3693            layer_bytes_total: 0,
3694            transient_layer_bytes_total: 0,
3695
3696            working_space: false,
3697            ws_tex: None,
3698            ws_view: None,
3699            ws_bind: None,
3700            display_pipeline: None,
3701            display_layout: None,
3702
3703            callback_resources: CallbackResources::default(),
3704            callback_scoped_resources: HashMap::new(),
3705            callback_scope_uses: HashMap::new(),
3706            callback_scope_payloads: HashMap::new(),
3707            callback_scope_clock: 0,
3708            blend_snapshot_bytes_total: 0,
3709            frame_active: false,
3710            last_render_error: None,
3711        };
3712
3713        renderer.recreate_msaa_and_depth_stencil();
3714        Ok(renderer)
3715    }
3716}
3717
3718/// wgpu backend names accepted by [`parse_backends`], mapped from the
3719/// canonical [`wgpu::Backend::to_str`] spelling plus the aliases people
3720/// actually type.
3721#[cfg(not(target_arch = "wasm32"))]
3722fn backend_by_name(name: &str) -> Option<wgpu::Backend> {
3723    match name {
3724        "gl" | "opengl" | "opengles" | "webgl" => Some(wgpu::Backend::Gl),
3725        "webgpu" | "browser-webgpu" => Some(wgpu::Backend::BrowserWebGpu),
3726        "metal" => Some(wgpu::Backend::Metal),
3727        "vulkan" => Some(wgpu::Backend::Vulkan),
3728        "dx12" | "d3d12" => Some(wgpu::Backend::Dx12),
3729        _ => None,
3730    }
3731}
3732
3733/// Parse a comma/space separated wgpu backend list such as `gl` or
3734/// `vulkan,gl`. Unknown names are dropped with a warning rather than
3735/// failing the whole spec, and `None` means "nothing valid was requested".
3736#[cfg(not(target_arch = "wasm32"))]
3737pub fn parse_backends(spec: &str) -> Option<wgpu::Backends> {
3738    let mut bits = wgpu::Backends::empty();
3739    for name in spec.split([',', ' ', '+']).filter(|n| !n.is_empty()) {
3740        match backend_by_name(&name.to_ascii_lowercase()) {
3741            Some(backend) => bits |= wgpu::Backends::from(backend),
3742            None => log::warn!(
3743                "REPOSE_WGPU_BACKENDS: ignoring unknown backend {name:?}; \
3744                 expected one of noop, vulkan, metal, dx12, gl, webgpu"
3745            ),
3746        }
3747    }
3748    (!bits.is_empty()).then_some(bits)
3749}
3750
3751#[cfg(feature = "winit-surface")]
3752pub(crate) async fn surface_instance(
3753    display: Option<winit::event_loop::OwnedDisplayHandle>,
3754) -> (Instance, wgpu::Backends) {
3755    // The display handle is what lets wgpu's GLES backend reach a real
3756    // platform surface. Without it wgpu falls back to a surfaceless/legacy-EGL
3757    // display, and on Wayland the GL adapter is then rejected outright as
3758    // surface-incompatible, which kills the OpenGL fallback entirely.
3759    let mut desc = match display {
3760        Some(handle) => wgpu::InstanceDescriptor::new_with_display_handle(Box::new(handle)),
3761        None => wgpu::InstanceDescriptor::new_without_display_handle(),
3762    };
3763    if cfg!(target_arch = "wasm32") {
3764        desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
3765    }
3766    #[cfg(not(target_arch = "wasm32"))]
3767    if let Ok(spec) = std::env::var("REPOSE_WGPU_BACKENDS")
3768        && let Some(bits) = parse_backends(&spec)
3769    {
3770        log::info!("REPOSE_WGPU_BACKENDS={spec} -> {bits:?}");
3771        desc.backends = bits;
3772    }
3773
3774    let backends = desc.backends;
3775    let instance = if cfg!(target_arch = "wasm32") {
3776        wgpu::util::new_instance_with_webgpu_detection(desc).await
3777    } else {
3778        wgpu::Instance::new(desc)
3779    };
3780    (instance, backends)
3781}
3782
3783impl WgpuSurfaceBackend {
3784    #[cfg(feature = "winit-surface")]
3785    pub async fn new_async(
3786        window: Arc<winit::window::Window>,
3787    ) -> anyhow::Result<WgpuSurfaceBackend> {
3788        Self::new_async_with_options(window, None, 4, PresentModePref::Auto).await
3789    }
3790
3791    /// Create a windowed surface backend, honoring the requested MSAA sample
3792    /// count (falling back to the largest supported count <= `msaa_samples`).
3793    #[cfg(feature = "winit-surface")]
3794    pub async fn new_async_with_msaa(
3795        window: Arc<winit::window::Window>,
3796        msaa_samples: u32,
3797    ) -> anyhow::Result<WgpuSurfaceBackend> {
3798        Self::new_async_with_options(window, None, msaa_samples, PresentModePref::Auto).await
3799    }
3800
3801    /// Create a windowed surface backend, honoring the requested MSAA sample
3802    /// count and present-mode preference.
3803    ///
3804    /// `display` must be the event loop's owned display handle on native
3805    /// platforms, otherwise the GL backend cannot present (see
3806    /// [`surface_instance`]).
3807    #[cfg(feature = "winit-surface")]
3808    pub async fn new_async_with_options(
3809        window: Arc<winit::window::Window>,
3810        display: Option<winit::event_loop::OwnedDisplayHandle>,
3811        msaa_samples: u32,
3812        present_mode: PresentModePref,
3813    ) -> anyhow::Result<WgpuSurfaceBackend> {
3814        let (instance, backends) = surface_instance(display).await;
3815
3816        let surface = instance.create_surface(window.clone())?;
3817
3818        let adapter = instance
3819            .request_adapter(&wgpu::RequestAdapterOptions {
3820                power_preference: wgpu::PowerPreference::HighPerformance,
3821                compatible_surface: Some(&surface),
3822                force_fallback_adapter: false,
3823                apply_limit_buckets: false,
3824            })
3825            .await
3826            .map_err(|e| {
3827                anyhow::anyhow!("no adapter for this window (enabled backends {backends:?}): {e:?}")
3828            })?;
3829        log::info!(
3830            "wgpu adapter: {} backend={:?} driver={:?} type={:?}",
3831            adapter.get_info().name,
3832            adapter.get_info().backend,
3833            adapter.get_info().driver,
3834            adapter.get_info().device_type,
3835        );
3836
3837        let limits = adapter.limits();
3838
3839        let features = {
3840            let available = adapter.features();
3841            let mut features = wgpu::Features::empty();
3842            if available.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) {
3843                features |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
3844            }
3845            #[cfg(target_os = "linux")]
3846            {
3847                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
3848                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
3849                }
3850                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
3851                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
3852                }
3853            }
3854            features
3855        };
3856
3857        let (device, queue) = adapter
3858            .request_device(&wgpu::DeviceDescriptor {
3859                label: Some("repose-rs device"),
3860                required_features: features,
3861                required_limits: limits,
3862                experimental_features: wgpu::ExperimentalFeatures::disabled(),
3863                memory_hints: wgpu::MemoryHints::default(),
3864                trace: wgpu::Trace::Off,
3865            })
3866            .await
3867            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
3868
3869        let device_lost = Arc::new(Mutex::new(None));
3870        {
3871            let device_lost = device_lost.clone();
3872            device.set_device_lost_callback(move |reason, message| {
3873                log::error!("wgpu device lost ({reason:?}): {message}");
3874                if let Ok(mut slot) = device_lost.lock() {
3875                    *slot = Some((reason, message));
3876                }
3877            });
3878        }
3879
3880        let size = window.inner_size();
3881
3882        let caps = surface.get_capabilities(&adapter);
3883
3884        let Some(format) = caps.formats.first().copied() else {
3885            anyhow::bail!(
3886                "surface exposes no presentable formats (adapter {})",
3887                adapter.get_info().name
3888            );
3889        };
3890
3891        let (format, view_format) = if cfg!(target_arch = "wasm32")
3892            && adapter
3893                .get_downlevel_capabilities()
3894                .flags
3895                .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS)
3896        {
3897            let non_srgb = caps
3898                .formats
3899                .iter()
3900                .copied()
3901                .find(|f| !f.is_srgb())
3902                .unwrap_or(format);
3903            (non_srgb, Some(non_srgb.add_srgb_suffix()))
3904        } else if cfg!(target_arch = "wasm32") {
3905            let fmt = caps
3906                .formats
3907                .iter()
3908                .copied()
3909                .find(|f| f.is_srgb())
3910                .unwrap_or(format);
3911            (fmt, None)
3912        } else {
3913            let fmt = caps
3914                .formats
3915                .iter()
3916                .copied()
3917                .find(|f| f.is_srgb())
3918                .unwrap_or(format);
3919            (fmt, None)
3920        };
3921
3922        let present_mode = pick_present_mode(&caps, present_mode);
3923        let alpha_mode = *caps
3924            .alpha_modes
3925            .first()
3926            .ok_or_else(|| anyhow::anyhow!("surface exposes no alpha modes"))?;
3927
3928        let render_format = view_format.unwrap_or(format);
3929        let msaa_samples = pick_surface_msaa(&adapter, render_format, msaa_samples);
3930        let mut renderer = WgpuSceneRenderer::try_from_device_with_adapter(
3931            &adapter,
3932            device,
3933            queue,
3934            render_format,
3935            msaa_samples,
3936        )?
3937        .with_surface_format(format)?;
3938        let (fw, fh) = clamp_output_size(
3939            size.width,
3940            size.height,
3941            renderer.device.limits().max_texture_dimension_2d,
3942        );
3943        renderer.resize(fw, fh);
3944
3945        let view_formats = view_format.into_iter().collect::<Vec<_>>();
3946
3947        // `COPY_SRC` lets an isolated blend snapshot the backdrop straight from
3948        // the swapchain when there is no multisample resolve target to copy
3949        // from. Canvas textures may not be storage-bound, but copying from them
3950        // is allowed. Not every backend grants it - GL surfaces are
3951        // render-target only - and the snapshot path already skips a source
3952        // without `COPY_SRC`, so request the intersection rather than making
3953        // `configure` fail and taking down every backend that lacks the usage.
3954        let wanted_usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
3955        let surface_usage = wanted_usage & caps.usages;
3956        if surface_usage != wanted_usage {
3957            log::warn!(
3958                "surface {format:?} cannot be used as {:?}; swapchain-sourced blend snapshots \
3959                 are skipped on this backend",
3960                wanted_usage - surface_usage
3961            );
3962        }
3963        let config = wgpu::SurfaceConfiguration {
3964            usage: surface_usage,
3965            format,
3966            width: fw.max(1),
3967            height: fh.max(1),
3968            present_mode,
3969            alpha_mode,
3970            color_space: wgpu::SurfaceColorSpace::Auto,
3971            view_formats,
3972            desired_maximum_frame_latency: 1,
3973        };
3974        surface.configure(&renderer.device, &config);
3975
3976        Ok(WgpuSurfaceBackend {
3977            #[cfg(feature = "winit-surface")]
3978            instance: Some(instance),
3979            #[cfg(feature = "winit-surface")]
3980            window: Some(window),
3981            surface: Some(surface),
3982            surface_config: Some(config),
3983            pending_reconfigure: false,
3984            renderer,
3985            device_lost,
3986        })
3987    }
3988
3989    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3990    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
3991        pollster::block_on(Self::new_async(window))
3992    }
3993
3994    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3995    pub fn new_with_msaa(
3996        window: Arc<winit::window::Window>,
3997        msaa_samples: u32,
3998    ) -> anyhow::Result<WgpuSurfaceBackend> {
3999        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
4000    }
4001
4002    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4003    pub fn new_with_options(
4004        window: Arc<winit::window::Window>,
4005        display: Option<winit::event_loop::OwnedDisplayHandle>,
4006        msaa_samples: u32,
4007        present_mode: PresentModePref,
4008    ) -> anyhow::Result<WgpuSurfaceBackend> {
4009        pollster::block_on(Self::new_async_with_options(
4010            window,
4011            display,
4012            msaa_samples,
4013            present_mode,
4014        ))
4015    }
4016
4017    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4018    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4019        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
4020    }
4021
4022    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4023    pub fn new_with_msaa(
4024        _window: Arc<winit::window::Window>,
4025        _msaa_samples: u32,
4026    ) -> anyhow::Result<WgpuSurfaceBackend> {
4027        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
4028    }
4029
4030    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4031    pub fn new_with_options(
4032        _window: Arc<winit::window::Window>,
4033        _display: Option<winit::event_loop::OwnedDisplayHandle>,
4034        _msaa_samples: u32,
4035        _present_mode: PresentModePref,
4036    ) -> anyhow::Result<WgpuSurfaceBackend> {
4037        anyhow::bail!(
4038            "Use WgpuSurfaceBackend::new_async_with_options(window, display, msaa, mode).await on wasm32"
4039        )
4040    }
4041
4042    /// Reason the device was lost, consumed once. `None` while healthy and
4043    /// after an ordinary `Destroyed`, which is what dropping this backend
4044    /// produces; runners use this to tell a crash/reset apart from teardown
4045    /// and rebuild the backend.
4046    pub fn take_device_lost(&self) -> Option<String> {
4047        let (reason, message) = self.device_lost.lock().ok()?.take()?;
4048        (reason != wgpu::DeviceLostReason::Destroyed).then_some(message)
4049    }
4050}
4051
4052/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
4053/// Fifo-first selection when the preferred mode is unavailable.
4054#[cfg(feature = "winit-surface")]
4055fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
4056    let auto = || {
4057        caps.present_modes
4058            .iter()
4059            .copied()
4060            .find(|m| *m == wgpu::PresentMode::Fifo)
4061            .or_else(|| {
4062                caps.present_modes
4063                    .iter()
4064                    .copied()
4065                    .find(|m| *m == wgpu::PresentMode::Mailbox)
4066            })
4067            .unwrap_or(wgpu::PresentMode::Immediate)
4068    };
4069    match pref {
4070        PresentModePref::Auto => auto(),
4071        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
4072            wgpu::PresentMode::Fifo
4073        }
4074        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
4075            wgpu::PresentMode::Mailbox
4076        }
4077        PresentModePref::Immediate
4078            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
4079        {
4080            wgpu::PresentMode::Immediate
4081        }
4082        _ => auto(),
4083    }
4084}
4085
4086/// `wgpu` only accepts these sample counts; anything else (3, 5, 17, 0) is
4087/// a hard validation error at pipeline creation.
4088const VALID_SAMPLE_COUNTS: [u32; 5] = [1, 2, 4, 8, 16];
4089
4090fn validate_sample_count(label: &str, samples: u32) -> anyhow::Result<()> {
4091    if !VALID_SAMPLE_COUNTS.contains(&samples) {
4092        anyhow::bail!(
4093            "{label} must be one of {VALID_SAMPLE_COUNTS:?} (wgpu-supported sample counts), got {samples}"
4094        );
4095    }
4096    Ok(())
4097}
4098
4099/// Every UI pipeline writes `output_format` with premultiplied-alpha
4100/// blending, so those two properties have to hold before any pipeline is
4101/// built. Multisample resolve is only required when more than one sample is
4102/// actually in use: a format that is renderable and blendable but cannot
4103/// resolve is fine at x1, which is what [`pick_surface_msaa`] falls back to.
4104fn validate_render_target_format(
4105    adapter: &wgpu::Adapter,
4106    output_format: wgpu::TextureFormat,
4107    samples: u32,
4108) -> anyhow::Result<()> {
4109    let features = adapter.get_texture_format_features(output_format);
4110    let flags = features.flags;
4111    for (needed, what) in [
4112        (wgpu::TextureUsages::RENDER_ATTACHMENT, "render attachment"),
4113        (wgpu::TextureUsages::TEXTURE_BINDING, "texture binding"),
4114    ] {
4115        if !features.allowed_usages.contains(needed) {
4116            anyhow::bail!("{output_format:?} cannot be used as a {what}");
4117        }
4118    }
4119    if !flags.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) {
4120        anyhow::bail!("{output_format:?} cannot be blended into");
4121    }
4122    if samples > 1 && !flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE) {
4123        anyhow::bail!(
4124            "{output_format:?} cannot resolve the requested {samples}x multisampled attachment"
4125        );
4126    }
4127    // `set_working_space` renders into Rgba16Float and resolves into it.
4128    let ws = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4129    if !ws
4130        .allowed_usages
4131        .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4132        || !ws
4133            .allowed_usages
4134            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4135    {
4136        anyhow::bail!(
4137            "{:?} cannot be used for linear working-space rendering",
4138            wgpu::TextureFormat::Rgba16Float
4139        );
4140    }
4141    Ok(())
4142}
4143
4144/// Pick the MSAA sample count for the surface pass, honoring `requested` and
4145/// falling back to the largest supported count <= it.
4146pub fn pick_surface_msaa(
4147    adapter: &wgpu::Adapter,
4148    format: wgpu::TextureFormat,
4149    requested: u32,
4150) -> u32 {
4151    pick_surface_msaa_for_mode(adapter, format, requested, false)
4152}
4153
4154pub fn pick_surface_msaa_for_mode(
4155    adapter: &wgpu::Adapter,
4156    format: wgpu::TextureFormat,
4157    requested: u32,
4158    working_space: bool,
4159) -> u32 {
4160    let requested = requested.max(1);
4161    let color_feat = adapter.get_texture_format_features(format);
4162    let working_space_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4163    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
4164    let supported = |n: u32| {
4165        color_feat
4166            .allowed_usages
4167            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4168            && color_feat.flags.sample_count_supported(n)
4169            && (!working_space
4170                || (working_space_feat
4171                    .allowed_usages
4172                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4173                    && working_space_feat.flags.sample_count_supported(n)
4174                    && (n == 1
4175                        || working_space_feat
4176                            .flags
4177                            .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))))
4178            && (n == 1
4179                || color_feat
4180                    .flags
4181                    .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))
4182            && depth_feat
4183                .allowed_usages
4184                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4185            && depth_feat.flags.sample_count_supported(n)
4186    };
4187    let mut candidates = vec![requested];
4188    for n in [8, 4, 2, 1] {
4189        if n < requested {
4190            candidates.push(n);
4191        }
4192    }
4193    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
4194    if chosen != requested {
4195        log::info!("requested MSAA x{requested}, using x{chosen}");
4196    }
4197    chosen
4198}
4199
4200impl WgpuSceneRenderer {
4201    // Image API
4202
4203    pub fn set_image_from_bytes(
4204        &mut self,
4205        handle: u64,
4206        data: &[u8],
4207        srgb: bool,
4208    ) -> anyhow::Result<()> {
4209        validate_image_handle(handle)?;
4210        let max_dimension = self.device.limits().max_texture_dimension_2d;
4211        let (w, h, rgba) = decode_image_rgba8(data, max_dimension)?;
4212        self.set_image_rgba8(handle, w, h, &rgba, srgb)?;
4213        // Encoded uploads have no size at the call site, so publish it here for
4214        // layout - only once the pixels are actually on the GPU.
4215        repose_core::set_image_intrinsic_size(handle, w, h);
4216        Ok(())
4217    }
4218
4219    pub fn set_image_rgba8(
4220        &mut self,
4221        handle: u64,
4222        w: u32,
4223        h: u32,
4224        rgba: &[u8],
4225        srgb: bool,
4226    ) -> anyhow::Result<()> {
4227        validate_image_handle(handle)?;
4228        validate_texture_dimensions(&self.device, w, h)?;
4229        let expected = checked_image_bytes_usize(w, h, 4)?;
4230        if rgba.len() < expected {
4231            return Err(anyhow::anyhow!(
4232                "RGBA buffer too small: {} < {}",
4233                rgba.len(),
4234                expected
4235            ));
4236        }
4237
4238        let format = if srgb {
4239            wgpu::TextureFormat::Rgba8UnormSrgb
4240        } else {
4241            wgpu::TextureFormat::Rgba8Unorm
4242        };
4243
4244        let needs_recreate = match self.images.get(&handle) {
4245            Some(ImageTex::Rgba {
4246                w: cw,
4247                h: ch,
4248                format: cf,
4249                ..
4250            }) => *cw != w || *ch != h || *cf != format,
4251            _ => true,
4252        };
4253
4254        if needs_recreate {
4255            self.remove_image(handle);
4256
4257            let (tex, binds) = self.create_rgba_tex(w, h, format);
4258            let bytes = checked_image_bytes(w, h, 4)?;
4259            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4260
4261            self.images.insert(
4262                handle,
4263                ImageTex::Rgba {
4264                    tex,
4265                    binds,
4266                    w,
4267                    h,
4268                    format,
4269                    last_used_frame: self.frame_index,
4270                    bytes,
4271                },
4272            );
4273        }
4274
4275        if let Some(old) = self.retained.remove(&handle) {
4276            self.retained_bytes_total = self
4277                .retained_bytes_total
4278                .saturating_sub(old.rgba.len() as u64);
4279        }
4280        self.retained_bytes_total = self.retained_bytes_total.saturating_add(expected as u64);
4281        self.retained.insert(
4282            handle,
4283            RetainedImage {
4284                w,
4285                h,
4286                format,
4287                rgba: rgba[..expected].to_vec(),
4288                last_used_frame: self.frame_index,
4289            },
4290        );
4291        self.evict_retained_excess();
4292
4293        let tex = match self.images.get(&handle) {
4294            Some(ImageTex::Rgba { tex, .. }) => tex,
4295            _ => unreachable!(),
4296        };
4297
4298        self.queue.write_texture(
4299            wgpu::TexelCopyTextureInfo {
4300                texture: tex,
4301                mip_level: 0,
4302                origin: wgpu::Origin3d::ZERO,
4303                aspect: wgpu::TextureAspect::All,
4304            },
4305            &rgba[..expected],
4306            wgpu::TexelCopyBufferLayout {
4307                offset: 0,
4308                bytes_per_row: Some(4 * w),
4309                rows_per_image: Some(h),
4310            },
4311            wgpu::Extent3d {
4312                width: w,
4313                height: h,
4314                depth_or_array_layers: 1,
4315            },
4316        );
4317
4318        // Ensure budget limits
4319        self.evict_budget_excess();
4320
4321        Ok(())
4322    }
4323
4324    /// Create (but do not populate) the GPU texture, view and bind group for an
4325    /// RGBA image. Pixels are written separately via `write_texture`.
4326    fn create_image_binds(
4327        &self,
4328        view: &wgpu::TextureView,
4329        label: &str,
4330        sampler: Option<&wgpu::Sampler>,
4331    ) -> ImageBinds {
4332        let make = |name: &str, sampler: &wgpu::Sampler| {
4333            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4334                label: Some(name),
4335                layout: &self.image_bind_layout_rgba,
4336                entries: &[
4337                    wgpu::BindGroupEntry {
4338                        binding: 0,
4339                        resource: wgpu::BindingResource::TextureView(view),
4340                    },
4341                    wgpu::BindGroupEntry {
4342                        binding: 1,
4343                        resource: wgpu::BindingResource::Sampler(sampler),
4344                    },
4345                ],
4346            })
4347        };
4348        match sampler {
4349            Some(sampler) => {
4350                let bind = make(label, sampler);
4351                ImageBinds {
4352                    linear: make(label, sampler),
4353                    nearest: bind.clone(),
4354                    tile_linear: bind.clone(),
4355                    tile_nearest: bind,
4356                }
4357            }
4358            None => ImageBinds {
4359                linear: make(label, &self.image_sampler),
4360                nearest: make(label, &self.image_sampler_nearest),
4361                tile_linear: make(label, &self.image_sampler_repeat),
4362                tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4363            },
4364        }
4365    }
4366
4367    fn create_nv12_binds(
4368        &self,
4369        view_y: &wgpu::TextureView,
4370        view_uv: &wgpu::TextureView,
4371        yuv_buf: &wgpu::Buffer,
4372        label: &str,
4373    ) -> ImageBinds {
4374        let make = |name: &str, sampler: &wgpu::Sampler| {
4375            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4376                label: Some(name),
4377                layout: &self.image_bind_layout_nv12,
4378                entries: &[
4379                    wgpu::BindGroupEntry {
4380                        binding: 0,
4381                        resource: wgpu::BindingResource::TextureView(view_y),
4382                    },
4383                    wgpu::BindGroupEntry {
4384                        binding: 1,
4385                        resource: wgpu::BindingResource::TextureView(view_uv),
4386                    },
4387                    wgpu::BindGroupEntry {
4388                        binding: 2,
4389                        resource: wgpu::BindingResource::Sampler(sampler),
4390                    },
4391                    wgpu::BindGroupEntry {
4392                        binding: 3,
4393                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4394                            buffer: yuv_buf,
4395                            offset: 0,
4396                            size: None,
4397                        }),
4398                    },
4399                ],
4400            })
4401        };
4402        ImageBinds {
4403            linear: make(label, &self.image_sampler),
4404            nearest: make(label, &self.image_sampler_nearest),
4405            tile_linear: make(label, &self.image_sampler_repeat),
4406            tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4407        }
4408    }
4409
4410    fn create_rgba_tex(
4411        &self,
4412        w: u32,
4413        h: u32,
4414        format: wgpu::TextureFormat,
4415    ) -> (wgpu::Texture, ImageBinds) {
4416        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
4417            label: Some("user image rgba"),
4418            size: wgpu::Extent3d {
4419                width: w,
4420                height: h,
4421                depth_or_array_layers: 1,
4422            },
4423            mip_level_count: 1,
4424            sample_count: 1,
4425            dimension: wgpu::TextureDimension::D2,
4426            format,
4427            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4428            view_formats: &[],
4429        });
4430        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
4431        let binds = self.create_image_binds(&view, "image bind rgba", None);
4432        (tex, binds)
4433    }
4434
4435    /// Register an externally-created `wgpu::TextureView` as an image (zero-copy).
4436    fn validate_native_texture_view(
4437        &self,
4438        view: &wgpu::TextureView,
4439        width: u32,
4440        height: u32,
4441    ) -> anyhow::Result<()> {
4442        if width == 0 || height == 0 {
4443            anyhow::bail!("native texture dimensions must be non-zero");
4444        }
4445        let texture = view.texture();
4446        if texture.sample_count() != 1
4447            || texture.dimension() != wgpu::TextureDimension::D2
4448            || texture.depth_or_array_layers() != 1
4449            || texture.mip_level_count() == 0
4450        {
4451            anyhow::bail!("native texture must be a single-sample 2D texture with mip levels");
4452        }
4453        if width > self.device.limits().max_texture_dimension_2d
4454            || height > self.device.limits().max_texture_dimension_2d
4455        {
4456            anyhow::bail!("native texture dimensions exceed the device limit");
4457        }
4458        if !texture
4459            .usage()
4460            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4461        {
4462            anyhow::bail!("native texture lacks TEXTURE_BINDING usage");
4463        }
4464        if !native_format_supported(texture.format(), self.device.features()) {
4465            anyhow::bail!(
4466                "native texture format {:?} is not a filterable alpha color format",
4467                texture.format()
4468            );
4469        }
4470        if texture.width() < width || texture.height() < height {
4471            anyhow::bail!(
4472                "native texture is {}x{}, smaller than requested {width}x{height}",
4473                texture.width(),
4474                texture.height()
4475            );
4476        }
4477        Ok(())
4478    }
4479
4480    pub fn register_native_texture(
4481        &mut self,
4482        view: &wgpu::TextureView,
4483        width: u32,
4484        height: u32,
4485    ) -> u64 {
4486        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4487            log::warn!("register_native_texture: {error:#}");
4488            return 0;
4489        }
4490        let handle = self.next_image_handle;
4491        self.next_image_handle += 1;
4492        let binds = self.create_image_binds(view, "user native image", None);
4493        self.images.insert(
4494            handle,
4495            ImageTex::User {
4496                binds,
4497                w: width,
4498                h: height,
4499                last_used_frame: self.frame_index,
4500                bytes: 0,
4501            },
4502        );
4503        handle
4504    }
4505
4506    /// Like `register_native_texture` but with custom sampler descriptor.
4507    pub fn register_native_texture_with_sampler(
4508        &mut self,
4509        view: &wgpu::TextureView,
4510        sampler_desc: wgpu::SamplerDescriptor<'_>,
4511        width: u32,
4512        height: u32,
4513    ) -> u64 {
4514        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4515            log::warn!("register_native_texture: {error:#}");
4516            return 0;
4517        }
4518        if let Err(error) = validate_sampler_descriptor(&self.device, &sampler_desc) {
4519            log::warn!("register_native_texture: {error:#}");
4520            return 0;
4521        }
4522        let handle = self.next_image_handle;
4523        self.next_image_handle += 1;
4524        let sampler = self.device.create_sampler(&sampler_desc);
4525        let binds = self.create_image_binds(view, "user native image sampleropts", Some(&sampler));
4526        self.images.insert(
4527            handle,
4528            ImageTex::User {
4529                binds,
4530                w: width,
4531                h: height,
4532                last_used_frame: self.frame_index,
4533                bytes: 0,
4534            },
4535        );
4536        handle
4537    }
4538
4539    /// Update an existing native texture handle with a new view (reuse handle).
4540    pub fn update_native_texture(&mut self, handle: u64, view: &wgpu::TextureView) {
4541        if handle == 0 {
4542            log::warn!("update_native_texture: reserved handle");
4543            return;
4544        }
4545        let Some((w, h)) = self.images.get(&handle).and_then(|entry| match entry {
4546            ImageTex::User { w, h, .. } => Some((*w, *h)),
4547            _ => None,
4548        }) else {
4549            log::warn!("update_native_texture: handle {handle} is not a native image");
4550            return;
4551        };
4552        if let Err(error) = self.validate_native_texture_view(view, w, h) {
4553            log::warn!("update_native_texture: {error:#}");
4554            return;
4555        }
4556        let binds = self.create_image_binds(view, "user native image update", None);
4557        if let Some(entry) = self.images.get_mut(&handle) {
4558            *entry = ImageTex::User {
4559                binds,
4560                w,
4561                h,
4562                last_used_frame: self.frame_index,
4563                bytes: 0,
4564            };
4565        }
4566    }
4567
4568    pub fn set_image_nv12(
4569        &mut self,
4570        handle: u64,
4571        w: u32,
4572        h: u32,
4573        y: &[u8],
4574        uv: &[u8],
4575        color_info: ColorInfo,
4576    ) -> anyhow::Result<()> {
4577        validate_image_handle(handle)?;
4578        validate_texture_dimensions(&self.device, w, h)?;
4579        if self
4580            .images
4581            .get(&handle)
4582            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4583        {
4584            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4585        }
4586        let y_expected = checked_image_bytes_usize(w, h, 1)?;
4587        let uv_w = w.div_ceil(2);
4588        let uv_h = h.div_ceil(2);
4589        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 2)?;
4590
4591        if y.len() < y_expected {
4592            return Err(anyhow::anyhow!("Y plane too small"));
4593        }
4594        if uv.len() < uv_expected {
4595            return Err(anyhow::anyhow!("UV plane too small"));
4596        }
4597
4598        let needs_recreate = match self.images.get(&handle) {
4599            Some(ImageTex::Nv12 {
4600                w: ww,
4601                h: hh,
4602                pixel_format,
4603                ..
4604            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::Nv12,
4605            _ => true,
4606        };
4607
4608        // Compute the YUV->RGB transform on the CPU.
4609        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::Nv12);
4610
4611        if needs_recreate {
4612            self.remove_image(handle);
4613
4614            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4615                label: Some("nv12 Y"),
4616                size: wgpu::Extent3d {
4617                    width: w,
4618                    height: h,
4619                    depth_or_array_layers: 1,
4620                },
4621                mip_level_count: 1,
4622                sample_count: 1,
4623                dimension: wgpu::TextureDimension::D2,
4624                format: wgpu::TextureFormat::R8Unorm,
4625                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4626                view_formats: &[],
4627            });
4628            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4629
4630            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4631                label: Some("nv12 UV"),
4632                size: wgpu::Extent3d {
4633                    width: uv_w,
4634                    height: uv_h,
4635                    depth_or_array_layers: 1,
4636                },
4637                mip_level_count: 1,
4638                sample_count: 1,
4639                dimension: wgpu::TextureDimension::D2,
4640                format: wgpu::TextureFormat::Rg8Unorm,
4641                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4642                view_formats: &[],
4643            });
4644            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4645
4646            // Create a uniform buffer for the YUV transform (per-image).
4647            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4648                label: Some("nv12 yuv transform"),
4649                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4650                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4651                mapped_at_creation: false,
4652            });
4653
4654            // Write initial transform.
4655            self.queue
4656                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4657
4658            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "nv12 bind");
4659
4660            let bytes = checked_image_bytes(w, h, 1)?
4661                .checked_add(checked_image_bytes(uv_w, uv_h, 2)?)
4662                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4663                .ok_or_else(|| anyhow::anyhow!("NV12 image byte size overflow"))?;
4664            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4665
4666            self.images.insert(
4667                handle,
4668                ImageTex::Nv12 {
4669                    tex_y,
4670                    tex_uv,
4671                    binds,
4672                    yuv_buf,
4673                    w,
4674                    h,
4675                    pixel_format: PixelFormat::Nv12,
4676                    fourcc: Some(NV12_FOURCC),
4677                    color_info,
4678                    external: false,
4679                    last_used_frame: self.frame_index,
4680                    bytes,
4681                },
4682            );
4683        } else {
4684            // Re-use existing textures; just update the YUV transform if needed.
4685            if let Some(ImageTex::Nv12 {
4686                yuv_buf,
4687                color_info: stored_color,
4688                last_used_frame,
4689                ..
4690            }) = self.images.get_mut(&handle)
4691            {
4692                *stored_color = color_info;
4693                *last_used_frame = self.frame_index;
4694                self.queue
4695                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4696            }
4697        }
4698
4699        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4700            Some(ImageTex::Nv12 {
4701                tex_y,
4702                tex_uv,
4703                binds,
4704                ..
4705            }) => (tex_y, tex_uv, binds),
4706            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
4707        };
4708
4709        self.queue.write_texture(
4710            wgpu::TexelCopyTextureInfo {
4711                texture: tex_y,
4712                mip_level: 0,
4713                origin: wgpu::Origin3d::ZERO,
4714                aspect: wgpu::TextureAspect::All,
4715            },
4716            &y[..y_expected],
4717            wgpu::TexelCopyBufferLayout {
4718                offset: 0,
4719                bytes_per_row: Some(w),
4720                rows_per_image: Some(h),
4721            },
4722            wgpu::Extent3d {
4723                width: w,
4724                height: h,
4725                depth_or_array_layers: 1,
4726            },
4727        );
4728
4729        self.queue.write_texture(
4730            wgpu::TexelCopyTextureInfo {
4731                texture: tex_uv,
4732                mip_level: 0,
4733                origin: wgpu::Origin3d::ZERO,
4734                aspect: wgpu::TextureAspect::All,
4735            },
4736            &uv[..uv_expected],
4737            wgpu::TexelCopyBufferLayout {
4738                offset: 0,
4739                bytes_per_row: Some(2 * uv_w),
4740                rows_per_image: Some(uv_h),
4741            },
4742            wgpu::Extent3d {
4743                width: uv_w,
4744                height: uv_h,
4745                depth_or_array_layers: 1,
4746            },
4747        );
4748
4749        self.evict_budget_excess();
4750        Ok(())
4751    }
4752
4753    pub fn set_image_planes(
4754        &mut self,
4755        handle: u64,
4756        w: u32,
4757        h: u32,
4758        pixel_format: PixelFormat,
4759        planes: &[&[u8]],
4760        color_info: ColorInfo,
4761    ) -> anyhow::Result<()> {
4762        validate_image_handle(handle)?;
4763        if planes.len() != pixel_format.num_planes() as usize {
4764            anyhow::bail!(
4765                "{} requires {} planes, got {}",
4766                match pixel_format {
4767                    PixelFormat::Nv12 => "NV12",
4768                    PixelFormat::P010 => "P010",
4769                    PixelFormat::I420 => "I420",
4770                    PixelFormat::I444 => "I444",
4771                    PixelFormat::Rgba => "RGBA",
4772                },
4773                pixel_format.num_planes(),
4774                planes.len()
4775            );
4776        }
4777        match pixel_format {
4778            PixelFormat::Nv12 => {
4779                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4780                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4781                self.set_image_nv12(handle, w, h, y, uv, color_info)
4782            }
4783            PixelFormat::P010 => {
4784                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4785                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4786                self.set_image_p010(handle, w, h, y, uv, color_info)
4787            }
4788            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
4789                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
4790            )),
4791            PixelFormat::Rgba => {
4792                let rgba = planes
4793                    .first()
4794                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
4795                self.set_image_rgba8(handle, w, h, rgba, false)
4796            }
4797        }
4798    }
4799
4800    fn set_image_p010(
4801        &mut self,
4802        handle: u64,
4803        w: u32,
4804        h: u32,
4805        y: &[u8],
4806        uv: &[u8],
4807        color_info: ColorInfo,
4808    ) -> anyhow::Result<()> {
4809        validate_image_handle(handle)?;
4810        validate_texture_dimensions(&self.device, w, h)?;
4811        if self
4812            .images
4813            .get(&handle)
4814            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4815        {
4816            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4817        }
4818        if !self
4819            .device
4820            .features()
4821            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
4822        {
4823            anyhow::bail!("P010 requires TEXTURE_FORMAT_16BIT_NORM");
4824        }
4825        let uv_w = w.div_ceil(2);
4826        let uv_h = h.div_ceil(2);
4827
4828        let y_expected = checked_image_bytes_usize(w, h, 2)?;
4829        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 4)?;
4830
4831        if y.len() < y_expected {
4832            return Err(anyhow::anyhow!("P010 Y plane too small"));
4833        }
4834        if uv.len() < uv_expected {
4835            return Err(anyhow::anyhow!("P010 UV plane too small"));
4836        }
4837
4838        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
4839        // abstracts the storage format so R16Unorm/Rg16Unorm are
4840        // filterable float textures just like R8Unorm/Rg8Unorm).
4841        let needs_recreate = match self.images.get(&handle) {
4842            Some(ImageTex::Nv12 {
4843                w: ww,
4844                h: hh,
4845                pixel_format,
4846                ..
4847            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::P010,
4848            _ => true,
4849        };
4850
4851        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::P010);
4852
4853        if needs_recreate {
4854            self.remove_image(handle);
4855
4856            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4857                label: Some("p010 Y"),
4858                size: wgpu::Extent3d {
4859                    width: w,
4860                    height: h,
4861                    depth_or_array_layers: 1,
4862                },
4863                mip_level_count: 1,
4864                sample_count: 1,
4865                dimension: wgpu::TextureDimension::D2,
4866                format: wgpu::TextureFormat::R16Unorm,
4867                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4868                view_formats: &[],
4869            });
4870            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4871
4872            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4873                label: Some("p010 UV"),
4874                size: wgpu::Extent3d {
4875                    width: uv_w,
4876                    height: uv_h,
4877                    depth_or_array_layers: 1,
4878                },
4879                mip_level_count: 1,
4880                sample_count: 1,
4881                dimension: wgpu::TextureDimension::D2,
4882                format: wgpu::TextureFormat::Rg16Unorm,
4883                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4884                view_formats: &[],
4885            });
4886            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4887
4888            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4889                label: Some("p010 yuv transform"),
4890                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4891                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4892                mapped_at_creation: false,
4893            });
4894            self.queue
4895                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4896
4897            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "p010 bind");
4898
4899            let bytes = checked_image_bytes(w, h, 2)?
4900                .checked_add(checked_image_bytes(uv_w, uv_h, 4)?)
4901                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4902                .ok_or_else(|| anyhow::anyhow!("P010 image byte size overflow"))?;
4903            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4904
4905            self.images.insert(
4906                handle,
4907                ImageTex::Nv12 {
4908                    tex_y,
4909                    tex_uv,
4910                    binds,
4911                    yuv_buf,
4912                    w,
4913                    h,
4914                    pixel_format: PixelFormat::P010,
4915                    fourcc: Some(P010_FOURCC),
4916                    color_info,
4917                    external: false,
4918                    last_used_frame: self.frame_index,
4919                    bytes,
4920                },
4921            );
4922        } else {
4923            if let Some(ImageTex::Nv12 {
4924                yuv_buf,
4925                color_info: stored_color,
4926                last_used_frame,
4927                ..
4928            }) = self.images.get_mut(&handle)
4929            {
4930                *stored_color = color_info;
4931                *last_used_frame = self.frame_index;
4932                self.queue
4933                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4934            }
4935        }
4936
4937        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4938            Some(ImageTex::Nv12 {
4939                tex_y,
4940                tex_uv,
4941                binds,
4942                ..
4943            }) => (tex_y, tex_uv, binds),
4944            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
4945        };
4946
4947        self.queue.write_texture(
4948            wgpu::TexelCopyTextureInfo {
4949                texture: tex_y,
4950                mip_level: 0,
4951                origin: wgpu::Origin3d::ZERO,
4952                aspect: wgpu::TextureAspect::All,
4953            },
4954            &y[..y_expected],
4955            wgpu::TexelCopyBufferLayout {
4956                offset: 0,
4957                bytes_per_row: Some(
4958                    w.checked_mul(2)
4959                        .ok_or_else(|| anyhow::anyhow!("P010 row size overflow"))?,
4960                ),
4961                rows_per_image: Some(h),
4962            },
4963            wgpu::Extent3d {
4964                width: w,
4965                height: h,
4966                depth_or_array_layers: 1,
4967            },
4968        );
4969        self.queue.write_texture(
4970            wgpu::TexelCopyTextureInfo {
4971                texture: tex_uv,
4972                mip_level: 0,
4973                origin: wgpu::Origin3d::ZERO,
4974                aspect: wgpu::TextureAspect::All,
4975            },
4976            &uv[..uv_expected],
4977            wgpu::TexelCopyBufferLayout {
4978                offset: 0,
4979                bytes_per_row: Some(
4980                    uv_w.checked_mul(4)
4981                        .ok_or_else(|| anyhow::anyhow!("P010 UV row size overflow"))?,
4982                ),
4983                rows_per_image: Some(uv_h),
4984            },
4985            wgpu::Extent3d {
4986                width: uv_w,
4987                height: uv_h,
4988                depth_or_array_layers: 1,
4989            },
4990        );
4991
4992        self.evict_budget_excess();
4993        Ok(())
4994    }
4995
4996    #[cfg(target_os = "linux")]
4997    pub fn set_image_dmabuf(
4998        &mut self,
4999        handle: u64,
5000        w: u32,
5001        h: u32,
5002        fds: Vec<std::os::unix::io::OwnedFd>,
5003        modifier: u64,
5004        strides: Vec<u32>,
5005        offsets: Vec<u64>,
5006        color_info: ColorInfo,
5007    ) -> anyhow::Result<()> {
5008        self.set_image_dmabuf_fourcc(
5009            handle,
5010            w,
5011            h,
5012            fds,
5013            NV12_FOURCC,
5014            modifier,
5015            strides,
5016            offsets,
5017            color_info,
5018        )
5019    }
5020
5021    #[cfg(target_os = "linux")]
5022    pub fn set_image_dmabuf_fourcc(
5023        &mut self,
5024        handle: u64,
5025        w: u32,
5026        h: u32,
5027        fds: Vec<std::os::unix::io::OwnedFd>,
5028        fourcc: u32,
5029        modifier: u64,
5030        strides: Vec<u32>,
5031        offsets: Vec<u64>,
5032        color_info: ColorInfo,
5033    ) -> anyhow::Result<()> {
5034        validate_image_handle(handle)?;
5035        checked_image_bytes(w, h, 1)?;
5036        if w == 0
5037            || h == 0
5038            || w > self.device.limits().max_texture_dimension_2d
5039            || h > self.device.limits().max_texture_dimension_2d
5040        {
5041            anyhow::bail!("DMA-BUF dimensions are outside the device texture limit");
5042        }
5043        if modifier == u64::MAX {
5044            anyhow::bail!("DMA-BUF modifier is invalid");
5045        }
5046        if fds.len() != 2 || strides.len() != 2 || offsets.len() != 2 {
5047            anyhow::bail!(
5048                "DMA-BUF import requires two fds, strides, and offsets (got {}, {}, {})",
5049                fds.len(),
5050                strides.len(),
5051                offsets.len()
5052            );
5053        }
5054        let pixel_format = match fourcc {
5055            0x3231_564e | 0x4e56_3132 => PixelFormat::Nv12,
5056            0x3031_3050 | 0x5030_3130 => PixelFormat::P010,
5057            _ => anyhow::bail!("unsupported DMA-BUF fourcc 0x{fourcc:08x}"),
5058        };
5059        if !self
5060            .device
5061            .features()
5062            .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF)
5063        {
5064            anyhow::bail!("DMA-BUF import requires VULKAN_EXTERNAL_MEMORY_DMA_BUF");
5065        }
5066        if pixel_format == PixelFormat::P010
5067            && !self
5068                .device
5069                .features()
5070                .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
5071        {
5072            anyhow::bail!("P010 DMA-BUF requires TEXTURE_FORMAT_16BIT_NORM");
5073        }
5074        let bytes_per_pixel = if pixel_format == PixelFormat::P010 {
5075            2
5076        } else {
5077            1
5078        };
5079        let uv_w = w.div_ceil(2);
5080        let uv_h = h.div_ceil(2);
5081        let y_file_size =
5082            validate_dmabuf_plane(&fds[0], offsets[0], strides[0], w, h, bytes_per_pixel)?;
5083        let uv_file_size = validate_dmabuf_plane(
5084            &fds[1],
5085            offsets[1],
5086            strides[1],
5087            uv_w,
5088            uv_h,
5089            bytes_per_pixel * 2,
5090        )?;
5091        let yuv_raw = make_yuv_transform_raw(color_info, pixel_format);
5092        let (y_format, uv_format) = if pixel_format == PixelFormat::P010 {
5093            (
5094                wgpu::TextureFormat::R16Unorm,
5095                wgpu::TextureFormat::Rg16Unorm,
5096            )
5097        } else {
5098            (wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::Rg8Unorm)
5099        };
5100        let hal_y_desc = wgpu::hal::TextureDescriptor {
5101            label: Some("dmabuf y"),
5102            size: wgpu::Extent3d {
5103                width: w,
5104                height: h,
5105                depth_or_array_layers: 1,
5106            },
5107            mip_level_count: 1,
5108            sample_count: 1,
5109            dimension: wgpu::TextureDimension::D2,
5110            format: y_format,
5111            usage: wgpu::wgt::TextureUses::RESOURCE,
5112            memory_flags: wgpu::hal::MemoryFlags::empty(),
5113            view_formats: vec![],
5114        };
5115        let hal_uv_desc = wgpu::hal::TextureDescriptor {
5116            label: Some("dmabuf uv"),
5117            size: wgpu::Extent3d {
5118                width: uv_w,
5119                height: uv_h,
5120                depth_or_array_layers: 1,
5121            },
5122            mip_level_count: 1,
5123            sample_count: 1,
5124            dimension: wgpu::TextureDimension::D2,
5125            format: uv_format,
5126            usage: wgpu::wgt::TextureUses::RESOURCE,
5127            memory_flags: wgpu::hal::MemoryFlags::empty(),
5128            view_formats: vec![],
5129        };
5130        let wgpu_y_desc = wgpu::TextureDescriptor {
5131            label: Some("dmabuf y"),
5132            size: hal_y_desc.size,
5133            mip_level_count: 1,
5134            sample_count: 1,
5135            dimension: wgpu::TextureDimension::D2,
5136            format: y_format,
5137            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5138            view_formats: &[],
5139        };
5140        let wgpu_uv_desc = wgpu::TextureDescriptor {
5141            label: Some("dmabuf uv"),
5142            size: hal_uv_desc.size,
5143            mip_level_count: 1,
5144            sample_count: 1,
5145            dimension: wgpu::TextureDimension::D2,
5146            format: uv_format,
5147            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5148            view_formats: &[],
5149        };
5150
5151        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
5152            let hal_guard = self
5153                .device
5154                .as_hal::<wgpu::hal::vulkan::Api>()
5155                .ok_or_else(|| anyhow::anyhow!("device is not Vulkan"))?;
5156            let mut fds = fds.into_iter();
5157            let y_fd = fds.next().expect("validated fd count");
5158            let uv_fd = fds.next().expect("validated fd count");
5159            let y_texture = hal_guard.texture_from_dmabuf_fd(
5160                y_fd,
5161                &hal_y_desc,
5162                modifier,
5163                strides[0] as u64,
5164                offsets[0],
5165            );
5166            let y_texture =
5167                y_texture.map_err(|error| anyhow::anyhow!("import Y DMA-BUF: {error:?}"))?;
5168            let uv_texture = hal_guard.texture_from_dmabuf_fd(
5169                uv_fd,
5170                &hal_uv_desc,
5171                modifier,
5172                strides[1] as u64,
5173                offsets[1],
5174            );
5175            let uv_texture =
5176                uv_texture.map_err(|error| anyhow::anyhow!("import UV DMA-BUF: {error:?}"))?;
5177            drop(hal_guard);
5178            let tex_y = self
5179                .device
5180                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5181                    y_texture,
5182                    &wgpu_y_desc,
5183                    wgpu::wgt::TextureUses::UNINITIALIZED,
5184                );
5185            let tex_uv = self
5186                .device
5187                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5188                    uv_texture,
5189                    &wgpu_uv_desc,
5190                    wgpu::wgt::TextureUses::UNINITIALIZED,
5191                );
5192            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
5193            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5194            (tex_y, view_y, tex_uv, view_uv)
5195        };
5196
5197        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5198            label: Some("dmabuf yuv transform"),
5199            size: std::mem::size_of::<YuvTransformRaw>() as u64,
5200            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5201            mapped_at_creation: false,
5202        });
5203        self.queue
5204            .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5205        let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "dmabuf yuv bind");
5206        let bytes = y_file_size
5207            .checked_add(uv_file_size)
5208            .and_then(|value| value.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5209            .ok_or_else(|| anyhow::anyhow!("DMA-BUF byte size overflow"))?;
5210        self.evict_budget_excess();
5211        let replaced_bytes = self
5212            .images
5213            .get(&handle)
5214            .map(|image| match image {
5215                ImageTex::Rgba { bytes, .. }
5216                | ImageTex::Nv12 { bytes, .. }
5217                | ImageTex::User { bytes, .. } => *bytes,
5218            })
5219            .unwrap_or(0);
5220        let projected = self
5221            .image_bytes_total
5222            .saturating_sub(replaced_bytes)
5223            .checked_add(bytes)
5224            .ok_or_else(|| anyhow::anyhow!("DMA-BUF image budget overflow"))?;
5225        if projected > self.image_budget_bytes {
5226            anyhow::bail!("DMA-BUF image budget exceeded");
5227        }
5228        self.remove_image(handle);
5229        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5230        self.images.insert(
5231            handle,
5232            ImageTex::Nv12 {
5233                tex_y,
5234                tex_uv,
5235                binds,
5236                yuv_buf,
5237                w,
5238                h,
5239                pixel_format,
5240                fourcc: Some(canonical_fourcc(pixel_format)),
5241                color_info,
5242                external: true,
5243                last_used_frame: self.frame_index,
5244                bytes,
5245            },
5246        );
5247        self.evict_budget_excess();
5248        Ok(())
5249    }
5250
5251    pub fn image_fourcc(&self, handle: u64) -> Option<u32> {
5252        match self.images.get(&handle) {
5253            Some(ImageTex::Nv12 { fourcc, .. }) => *fourcc,
5254            _ => None,
5255        }
5256    }
5257
5258    pub fn remove_image(&mut self, handle: u64) {
5259        if let Some(img) = self.images.remove(&handle) {
5260            let b = match &img {
5261                ImageTex::Rgba { bytes, .. } => *bytes,
5262                ImageTex::Nv12 { bytes, .. } => *bytes,
5263                ImageTex::User { bytes, .. } => *bytes,
5264            };
5265            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5266        }
5267        if let Some(retained) = self.retained.remove(&handle) {
5268            self.retained_bytes_total = self
5269                .retained_bytes_total
5270                .saturating_sub(retained.rgba.len() as u64);
5271        }
5272    }
5273
5274    fn evict_image_gpu(&mut self, handle: u64) -> u64 {
5275        let Some(img) = self.images.remove(&handle) else {
5276            return 0;
5277        };
5278        let b = match &img {
5279            ImageTex::Rgba { bytes, .. } => *bytes,
5280            ImageTex::Nv12 { bytes, .. } => *bytes,
5281            ImageTex::User { bytes, .. } => *bytes,
5282        };
5283        self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5284        b
5285    }
5286
5287    fn revive_retained_image(&mut self, handle: u64) -> anyhow::Result<bool> {
5288        if self.images.contains_key(&handle) {
5289            return Ok(true);
5290        }
5291        let Some(r) = self.retained.get_mut(&handle) else {
5292            return Ok(false);
5293        };
5294        r.last_used_frame = self.frame_index;
5295        let r = r.clone();
5296        let (tex, binds) = self.create_rgba_tex(r.w, r.h, r.format);
5297
5298        self.queue.write_texture(
5299            wgpu::TexelCopyTextureInfo {
5300                texture: &tex,
5301                mip_level: 0,
5302                origin: wgpu::Origin3d::ZERO,
5303                aspect: wgpu::TextureAspect::All,
5304            },
5305            &r.rgba,
5306            wgpu::TexelCopyBufferLayout {
5307                offset: 0,
5308                bytes_per_row: Some(4 * r.w),
5309                rows_per_image: Some(r.h),
5310            },
5311            wgpu::Extent3d {
5312                width: r.w,
5313                height: r.h,
5314                depth_or_array_layers: 1,
5315            },
5316        );
5317
5318        let bytes = checked_image_bytes(r.w, r.h, 4)?;
5319        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5320        self.images.insert(
5321            handle,
5322            ImageTex::Rgba {
5323                tex,
5324                binds,
5325                w: r.w,
5326                h: r.h,
5327                format: r.format,
5328                last_used_frame: self.frame_index,
5329                bytes,
5330            },
5331        );
5332        Ok(true)
5333    }
5334
5335    fn resolve_image_for_draw(&mut self, handle: u64) -> Option<(u32, u32, bool)> {
5336        if let Some(retained) = self.retained.get_mut(&handle) {
5337            retained.last_used_frame = self.frame_index;
5338        }
5339        if let Some(t) = self.images.get_mut(&handle) {
5340            return match t {
5341                ImageTex::Rgba {
5342                    w,
5343                    h,
5344                    last_used_frame,
5345                    ..
5346                } => {
5347                    *last_used_frame = self.frame_index;
5348                    Some((*w, *h, false))
5349                }
5350                ImageTex::User {
5351                    w,
5352                    h,
5353                    last_used_frame,
5354                    ..
5355                } => {
5356                    *last_used_frame = self.frame_index;
5357                    Some((*w, *h, false))
5358                }
5359                ImageTex::Nv12 {
5360                    w,
5361                    h,
5362                    last_used_frame,
5363                    ..
5364                } => {
5365                    *last_used_frame = self.frame_index;
5366                    Some((*w, *h, true))
5367                }
5368            };
5369        }
5370        if self.revive_retained_image(handle).unwrap_or(false)
5371            && let Some(ImageTex::Rgba {
5372                w,
5373                h,
5374                last_used_frame,
5375                ..
5376            }) = self.images.get_mut(&handle)
5377        {
5378            *last_used_frame = self.frame_index;
5379            return Some((*w, *h, false));
5380        }
5381        None
5382    }
5383
5384    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
5385    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
5386        let handle = self.next_image_handle;
5387        self.next_image_handle += 1;
5388        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
5389            log::error!("Failed to register image: {e}");
5390        }
5391        handle
5392    }
5393
5394    /// Register raw RGBA8 pixels (`w * h * 4` bytes) as an image, returning
5395    /// its handle for `SceneNode::Image`. Used by CPU-rasterized overlays
5396    /// (e.g. subtitle bitmap layers) that have no encoded image bytes.
5397    /// Pass `srgb = true` for sRGB overlays composited over video.
5398    pub fn register_image_rgba8(&mut self, w: u32, h: u32, rgba: &[u8], srgb: bool) -> u64 {
5399        let handle = self.next_image_handle;
5400        self.next_image_handle += 1;
5401        if let Err(e) = self.set_image_rgba8(handle, w, h, rgba, srgb) {
5402            log::error!("Failed to register image: {e}");
5403        }
5404        handle
5405    }
5406
5407    /// Register an 8-bit coverage tile (`w * h` bytes, 0 = empty, 255 =
5408    /// fully covered) for `SceneNode::Coverage`, returning its handle.
5409    /// Coverage tiles are immutable: re-register on geometry change and
5410    /// `remove_coverage` handles you no longer emit (stale tiles also age
5411    /// out under the image eviction policy).
5412    pub fn register_coverage_a8(&mut self, w: u32, h: u32, coverage: &[u8]) -> u64 {
5413        if validate_texture_dimensions(&self.device, w, h).is_err() {
5414            log::error!("Coverage dimensions are outside the device limit");
5415            return 0;
5416        }
5417        let Ok(expected_bytes) = checked_image_bytes(w, h, 1) else {
5418            log::error!("Coverage dimensions overflow");
5419            return 0;
5420        };
5421        let Ok(expected) = usize::try_from(expected_bytes) else {
5422            log::error!("Coverage dimensions exceed addressable memory");
5423            return 0;
5424        };
5425        if coverage.len() < expected {
5426            log::error!("Coverage buffer too small: {} < {expected}", coverage.len());
5427            return 0;
5428        }
5429        let handle = self.next_coverage_handle;
5430        self.next_coverage_handle += 1;
5431
5432        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5433            label: Some("coverage tile a8"),
5434            size: wgpu::Extent3d {
5435                width: w,
5436                height: h,
5437                depth_or_array_layers: 1,
5438            },
5439            mip_level_count: 1,
5440            sample_count: 1,
5441            dimension: wgpu::TextureDimension::D2,
5442            format: wgpu::TextureFormat::R8Unorm,
5443            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5444            view_formats: &[],
5445        });
5446        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5447        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5448            label: Some("coverage bind a8"),
5449            layout: &self.image_bind_layout_rgba,
5450            entries: &[
5451                wgpu::BindGroupEntry {
5452                    binding: 0,
5453                    resource: wgpu::BindingResource::TextureView(&view),
5454                },
5455                wgpu::BindGroupEntry {
5456                    binding: 1,
5457                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5458                },
5459            ],
5460        });
5461        self.queue.write_texture(
5462            wgpu::TexelCopyTextureInfo {
5463                texture: &tex,
5464                mip_level: 0,
5465                origin: wgpu::Origin3d::ZERO,
5466                aspect: wgpu::TextureAspect::All,
5467            },
5468            &coverage[..expected],
5469            wgpu::TexelCopyBufferLayout {
5470                offset: 0,
5471                bytes_per_row: Some(w),
5472                rows_per_image: Some(h),
5473            },
5474            wgpu::Extent3d {
5475                width: w,
5476                height: h,
5477                depth_or_array_layers: 1,
5478            },
5479        );
5480        let bytes = checked_image_bytes(w, h, 1).unwrap_or(u64::MAX);
5481        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5482        self.coverages.insert(
5483            handle,
5484            CoverageTex {
5485                tex,
5486                bind,
5487                w,
5488                h,
5489                last_used_frame: self.frame_index,
5490                bytes,
5491            },
5492        );
5493        self.evict_budget_excess();
5494        handle
5495    }
5496
5497    /// Remove a coverage tile registered with [`register_coverage_a8`](Self::register_coverage_a8).
5498    pub fn remove_coverage(&mut self, handle: u64) {
5499        if let Some(tile) = self.coverages.remove(&handle) {
5500            self.image_bytes_total = self.image_bytes_total.saturating_sub(tile.bytes);
5501        }
5502    }
5503
5504    /// Tile dimensions, marking the handle used (keeps it alive under the
5505    /// eviction policy). Returns `None` for unknown handles.
5506    pub fn coverage_dimensions(&mut self, handle: u64) -> Option<(u32, u32)> {
5507        if let Some(tile) = self.coverages.get_mut(&handle) {
5508            tile.last_used_frame = self.frame_index;
5509            return Some((tile.w, tile.h));
5510        }
5511        None
5512    }
5513
5514    fn evict_retained_excess(&mut self) {
5515        let mut candidates: Vec<(u64, u64, u64)> = self
5516            .retained
5517            .iter()
5518            .map(|(handle, image)| (*handle, image.last_used_frame, image.rgba.len() as u64))
5519            .collect();
5520        candidates.sort_by_key(|candidate| candidate.1);
5521        for (handle, _last_used, bytes) in candidates {
5522            if self.retained.len() <= MAX_RETAINED_IMAGES
5523                && self.retained_bytes_total <= MAX_RETAINED_IMAGE_BYTES
5524            {
5525                break;
5526            }
5527            if let Some(image) = self.retained.remove(&handle) {
5528                self.retained_bytes_total = self
5529                    .retained_bytes_total
5530                    .saturating_sub(bytes.min(image.rgba.len() as u64));
5531            }
5532        }
5533    }
5534
5535    fn evict_unused_images(&mut self) {
5536        let now = self.frame_index;
5537        let evict_after = self.image_evict_after_frames;
5538
5539        // Time based eviction. Eviction only frees GPU memory: retained RGBA
5540        // sources stay so the image can be lazily re-uploaded when drawn again.
5541        let mut to_evict = Vec::new();
5542        for (h, t) in self.images.iter() {
5543            if !t.evictable() {
5544                continue;
5545            }
5546            let last = match t {
5547                ImageTex::Rgba {
5548                    last_used_frame, ..
5549                } => *last_used_frame,
5550                ImageTex::User {
5551                    last_used_frame, ..
5552                } => *last_used_frame,
5553                ImageTex::Nv12 {
5554                    last_used_frame, ..
5555                } => *last_used_frame,
5556            };
5557            if now.saturating_sub(last) > evict_after {
5558                to_evict.push(*h);
5559            }
5560        }
5561        for h in to_evict {
5562            if self.retained.contains_key(&h) {
5563                self.evict_image_gpu(h);
5564            } else {
5565                self.remove_image(h);
5566            }
5567        }
5568
5569        // Coverage tiles have no retained CPU copies: age-out removes them.
5570        let mut stale = Vec::new();
5571        for (h, t) in self.coverages.iter() {
5572            if now.saturating_sub(t.last_used_frame) > evict_after {
5573                stale.push(*h);
5574            }
5575        }
5576        for h in stale {
5577            self.remove_coverage(h);
5578        }
5579
5580        self.evict_budget_excess();
5581    }
5582
5583    fn next_cache_touch(&mut self) -> u64 {
5584        let touch = self.cache_touch_clock;
5585        self.cache_touch_clock = self.cache_touch_clock.wrapping_add(1);
5586        touch
5587    }
5588
5589    fn gpu_bytes_total(&self) -> u64 {
5590        self.image_bytes_total
5591            .saturating_add(self.layer_bytes_total)
5592            .saturating_add(self.blend_snapshot_bytes_total)
5593            .saturating_add(self.mesh_cache_bytes_total)
5594            .saturating_add(self.working_space_bytes)
5595            .saturating_add(self.surface_resolve_bytes)
5596            .saturating_add(self.msaa_bytes)
5597            .saturating_add(self.ws_msaa_bytes)
5598            .saturating_add(self.depth_stencil_bytes)
5599    }
5600
5601    fn budget_exceeded(&self) -> bool {
5602        self.image_bytes_total > self.image_budget_bytes
5603            || self.gpu_bytes_total() > self.gpu_budget_bytes
5604    }
5605
5606    fn evict_budget_excess(&mut self) {
5607        let image_over_budget = self.image_bytes_total > self.image_budget_bytes;
5608        let gpu_over_budget = self.gpu_bytes_total() > self.gpu_budget_bytes;
5609        if !image_over_budget && !gpu_over_budget {
5610            return;
5611        }
5612        let mut coverage_candidates: Vec<(u64, u64)> = self
5613            .coverages
5614            .iter()
5615            .map(|(handle, tile)| (*handle, tile.last_used_frame))
5616            .collect();
5617        coverage_candidates.sort_by_key(|candidate| candidate.1);
5618        let now = self.frame_index;
5619        for (handle, last_used) in coverage_candidates {
5620            if !self.budget_exceeded() {
5621                break;
5622            }
5623            if last_used == now {
5624                continue;
5625            }
5626            self.remove_coverage(handle);
5627        }
5628        if !self.budget_exceeded() {
5629            return;
5630        }
5631        // Collect (handle, last_used, bytes)
5632        let mut candidates: Vec<(u64, u64, u64)> = self
5633            .images
5634            .iter()
5635            .filter_map(|(h, t)| {
5636                if !t.evictable() {
5637                    return None;
5638                }
5639                let (last, bytes) = match t {
5640                    ImageTex::Rgba {
5641                        last_used_frame,
5642                        bytes,
5643                        ..
5644                    } => (*last_used_frame, *bytes),
5645                    ImageTex::User {
5646                        last_used_frame,
5647                        bytes,
5648                        ..
5649                    } => (*last_used_frame, *bytes),
5650                    ImageTex::Nv12 {
5651                        last_used_frame,
5652                        bytes,
5653                        ..
5654                    } => (*last_used_frame, *bytes),
5655                };
5656                Some((*h, last, bytes))
5657            })
5658            .collect();
5659
5660        // Sort by last_used ascending (LRU first)
5661        candidates.sort_by_key(|k| k.1);
5662
5663        for (h, last, _bytes) in candidates {
5664            if !self.budget_exceeded() {
5665                break;
5666            }
5667            if last == now {
5668                continue;
5669            }
5670            if self.retained.contains_key(&h) {
5671                self.evict_image_gpu(h);
5672            } else {
5673                self.remove_image(h);
5674            }
5675        }
5676        while self.gpu_bytes_total() > self.gpu_budget_bytes {
5677            if !self.evict_mesh_cache() {
5678                break;
5679            }
5680        }
5681        if self.gpu_bytes_total() > self.gpu_budget_bytes {
5682            log::warn!(
5683                "renderer GPU resource budget exceeded: {} > {}",
5684                self.gpu_bytes_total(),
5685                self.gpu_budget_bytes
5686            );
5687        }
5688    }
5689
5690    /// Set pixels per point (DPI scale) for callback `ScreenDescriptor` / `PaintCallbackInfo`.
5691    pub fn set_pixels_per_point(&mut self, ppp: f32) {
5692        if ppp.is_finite() {
5693            self.pixels_per_point = ppp.clamp(0.5, 8.0);
5694        }
5695    }
5696
5697    /// Enable or disable linear working-space rendering.
5698    /// When enabled, the scene is rendered into an Rgba16Float intermediate
5699    /// and a final full-screen pass applies the display OETF.
5700    pub fn set_working_space(&mut self, enabled: bool) {
5701        if enabled == self.working_space {
5702            return;
5703        }
5704        self.working_space = enabled;
5705        if enabled {
5706            self.ensure_display_pipeline();
5707            self.recreate_msaa_and_depth_stencil();
5708            self.recreate_working_space_texture();
5709        } else {
5710            self.ws_tex = None;
5711            self.ws_view = None;
5712            self.ws_bind = None;
5713            self.ws_msaa_tex = None;
5714            self.ws_msaa_view = None;
5715            self.surface_resolve_tex = None;
5716            self.surface_resolve_view = None;
5717            self.surface_resolve_bytes = 0;
5718            self.working_space_bytes = 0;
5719            self.recreate_msaa_and_depth_stencil();
5720        }
5721        self.evict_budget_excess();
5722    }
5723
5724    fn ensure_display_pipeline(&mut self) {
5725        if self.display_pipeline.is_some() {
5726            return;
5727        }
5728
5729        let layout = self
5730            .device
5731            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5732                label: Some("display transform layout"),
5733                entries: &[
5734                    wgpu::BindGroupLayoutEntry {
5735                        binding: 0,
5736                        visibility: wgpu::ShaderStages::FRAGMENT,
5737                        ty: wgpu::BindingType::Texture {
5738                            multisampled: false,
5739                            view_dimension: wgpu::TextureViewDimension::D2,
5740                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5741                        },
5742                        count: None,
5743                    },
5744                    wgpu::BindGroupLayoutEntry {
5745                        binding: 1,
5746                        visibility: wgpu::ShaderStages::FRAGMENT,
5747                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5748                        count: None,
5749                    },
5750                ],
5751            });
5752        self.display_layout = Some(layout);
5753
5754        let display_source: Cow<'static, str> = if self.output_format.is_srgb() {
5755            Cow::Borrowed(include_str!("shaders/display_transform_passthrough.wgsl"))
5756        } else {
5757            Cow::Borrowed(include_str!("shaders/display_transform.wgsl"))
5758        };
5759        let shader = self
5760            .device
5761            .create_shader_module(wgpu::ShaderModuleDescriptor {
5762                label: Some("display transform"),
5763                source: wgpu::ShaderSource::Wgsl(display_source),
5764            });
5765
5766        let pipeline_layout = self
5767            .device
5768            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
5769                label: Some("display transform pipeline layout"),
5770                bind_group_layouts: &[None, self.display_layout.as_ref()],
5771                immediate_size: 0,
5772            });
5773
5774        let pipeline = self
5775            .device
5776            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
5777                label: Some("display transform pipeline"),
5778                layout: Some(&pipeline_layout),
5779                vertex: wgpu::VertexState {
5780                    module: &shader,
5781                    entry_point: Some("vs_main"),
5782                    buffers: &[],
5783                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5784                },
5785                fragment: Some(wgpu::FragmentState {
5786                    module: &shader,
5787                    entry_point: Some("fs_main"),
5788                    targets: &[Some(wgpu::ColorTargetState {
5789                        format: self.output_format,
5790                        blend: None,
5791                        write_mask: wgpu::ColorWrites::ALL,
5792                    })],
5793                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5794                }),
5795                primitive: wgpu::PrimitiveState::default(),
5796                depth_stencil: None,
5797                multisample: wgpu::MultisampleState::default(),
5798                multiview_mask: None,
5799                cache: None,
5800            });
5801        self.display_pipeline = Some(pipeline);
5802    }
5803
5804    /// Resize the render target dimensions.
5805    ///
5806    /// Recreates MSAA, depth-stencil, and working-space textures to match the
5807    /// new size..
5808    pub fn resize(&mut self, width: u32, height: u32) {
5809        let (width, height) =
5810            clamp_output_size(width, height, self.device.limits().max_texture_dimension_2d);
5811        if self.output_width == width && self.output_height == height {
5812            return;
5813        }
5814        self.output_width = width;
5815        self.output_height = height;
5816        if width == 0 || height == 0 {
5817            self.msaa_tex = None;
5818            self.msaa_view = None;
5819            self.ws_msaa_tex = None;
5820            self.ws_msaa_view = None;
5821            self.surface_resolve_tex = None;
5822            self.surface_resolve_view = None;
5823            self.surface_resolve_bytes = 0;
5824            self.msaa_bytes = 0;
5825            self.ws_msaa_bytes = 0;
5826            self.depth_stencil_bytes = 0;
5827            self.working_space_bytes = 0;
5828            self.ws_tex = None;
5829            self.ws_view = None;
5830            self.ws_bind = None;
5831            self.evict_budget_excess();
5832            return;
5833        }
5834        self.recreate_msaa_and_depth_stencil();
5835        self.recreate_working_space_texture();
5836        self.evict_budget_excess();
5837    }
5838
5839    fn recreate_working_space_texture(&mut self) {
5840        if !self.working_space || self.output_width == 0 || self.output_height == 0 {
5841            return;
5842        }
5843        self.working_space_bytes = 0;
5844        let w = self
5845            .output_width
5846            .clamp(1, self.device.limits().max_texture_dimension_2d);
5847        let h = self
5848            .output_height
5849            .clamp(1, self.device.limits().max_texture_dimension_2d);
5850
5851        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5852            label: Some("working space"),
5853            size: wgpu::Extent3d {
5854                width: w,
5855                height: h,
5856                depth_or_array_layers: 1,
5857            },
5858            mip_level_count: 1,
5859            sample_count: 1,
5860            dimension: wgpu::TextureDimension::D2,
5861            format: wgpu::TextureFormat::Rgba16Float,
5862            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5863                | wgpu::TextureUsages::TEXTURE_BINDING
5864                | wgpu::TextureUsages::COPY_SRC
5865                | wgpu::TextureUsages::COPY_DST,
5866            view_formats: &[],
5867        });
5868        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5869
5870        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5871            label: Some("working space bind"),
5872            layout: self.display_layout.as_ref().unwrap(),
5873            entries: &[
5874                wgpu::BindGroupEntry {
5875                    binding: 0,
5876                    resource: wgpu::BindingResource::TextureView(&view),
5877                },
5878                wgpu::BindGroupEntry {
5879                    binding: 1,
5880                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5881                },
5882            ],
5883        });
5884
5885        self.working_space_bytes =
5886            texture_storage_bytes(wgpu::TextureFormat::Rgba16Float, w, h).unwrap_or(0);
5887        self.ws_tex = Some(tex);
5888        self.ws_view = Some(view);
5889        self.ws_bind = Some(bind);
5890    }
5891
5892    fn recreate_msaa_and_depth_stencil(&mut self) {
5893        if self.output_width == 0 || self.output_height == 0 {
5894            self.msaa_tex = None;
5895            self.msaa_view = None;
5896            self.ws_msaa_tex = None;
5897            self.ws_msaa_view = None;
5898            self.surface_resolve_tex = None;
5899            self.surface_resolve_view = None;
5900            self.surface_resolve_bytes = 0;
5901            self.msaa_bytes = 0;
5902            self.ws_msaa_bytes = 0;
5903            self.depth_stencil_bytes = 0;
5904            return;
5905        }
5906        self.msaa_bytes = 0;
5907        self.ws_msaa_bytes = 0;
5908        self.depth_stencil_bytes = 0;
5909        let aliased_surface = self.surface_format != self.output_format;
5910        let view_format_list = [self.output_format];
5911        let surface_view_formats: &[wgpu::TextureFormat] = if aliased_surface {
5912            &view_format_list
5913        } else {
5914            &[]
5915        };
5916        let surface_view = if aliased_surface {
5917            wgpu::TextureViewDescriptor {
5918                format: Some(self.output_format),
5919                ..Default::default()
5920            }
5921        } else {
5922            wgpu::TextureViewDescriptor::default()
5923        };
5924        if self.msaa_samples > 1 && !self.working_space {
5925            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5926                label: Some("msaa color"),
5927                size: wgpu::Extent3d {
5928                    width: self.output_width.max(1),
5929                    height: self.output_height.max(1),
5930                    depth_or_array_layers: 1,
5931                },
5932                mip_level_count: 1,
5933                sample_count: self.msaa_samples,
5934                dimension: wgpu::TextureDimension::D2,
5935                format: self.surface_format,
5936                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5937                view_formats: surface_view_formats,
5938            });
5939            let view = tex.create_view(&surface_view);
5940            self.msaa_bytes = texture_storage_bytes_with_samples(
5941                self.surface_format,
5942                self.output_width.max(1),
5943                self.output_height.max(1),
5944                self.msaa_samples,
5945            )
5946            .unwrap_or(0);
5947            self.msaa_tex = Some(tex);
5948            self.msaa_view = Some(view);
5949        } else {
5950            self.msaa_tex = None;
5951            self.msaa_view = None;
5952        }
5953        if self.working_space && self.working_space_msaa_samples > 1 {
5954            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5955                label: Some("working space msaa color"),
5956                size: wgpu::Extent3d {
5957                    width: self.output_width.max(1),
5958                    height: self.output_height.max(1),
5959                    depth_or_array_layers: 1,
5960                },
5961                mip_level_count: 1,
5962                sample_count: self.working_space_msaa_samples,
5963                dimension: wgpu::TextureDimension::D2,
5964                format: wgpu::TextureFormat::Rgba16Float,
5965                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5966                view_formats: &[],
5967            });
5968            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5969            self.ws_msaa_bytes = texture_storage_bytes_with_samples(
5970                wgpu::TextureFormat::Rgba16Float,
5971                self.output_width.max(1),
5972                self.output_height.max(1),
5973                self.working_space_msaa_samples,
5974            )
5975            .unwrap_or(0);
5976            self.ws_msaa_tex = Some(tex);
5977            self.ws_msaa_view = Some(view);
5978        } else {
5979            self.ws_msaa_tex = None;
5980            self.ws_msaa_view = None;
5981        }
5982        self.surface_resolve_tex = None;
5983        self.surface_resolve_view = None;
5984        self.surface_resolve_bytes = 0;
5985        if self.msaa_samples > 1 && !self.working_space {
5986            let width = self.output_width.max(1);
5987            let height = self.output_height.max(1);
5988            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5989                label: Some("surface resolve"),
5990                size: wgpu::Extent3d {
5991                    width,
5992                    height,
5993                    depth_or_array_layers: 1,
5994                },
5995                mip_level_count: 1,
5996                sample_count: 1,
5997                dimension: wgpu::TextureDimension::D2,
5998                format: self.surface_format,
5999                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6000                    | wgpu::TextureUsages::TEXTURE_BINDING
6001                    | wgpu::TextureUsages::COPY_SRC,
6002                view_formats: surface_view_formats,
6003            });
6004            let view = tex.create_view(&surface_view);
6005            self.surface_resolve_bytes =
6006                texture_storage_bytes(self.surface_format, width, height).unwrap_or(0);
6007            self.surface_resolve_tex = Some(tex);
6008            self.surface_resolve_view = Some(view);
6009        }
6010
6011        self.depth_stencil_bytes = texture_storage_bytes_with_samples(
6012            wgpu::TextureFormat::Depth24PlusStencil8,
6013            self.output_width.max(1),
6014            self.output_height.max(1),
6015            self.active_surface_msaa_samples(),
6016        )
6017        .unwrap_or(0);
6018        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
6019            label: Some("depth-stencil (stencil clips)"),
6020            size: wgpu::Extent3d {
6021                width: self.output_width.max(1),
6022                height: self.output_height.max(1),
6023                depth_or_array_layers: 1,
6024            },
6025            mip_level_count: 1,
6026            sample_count: self.active_surface_msaa_samples(),
6027            dimension: wgpu::TextureDimension::D2,
6028            format: wgpu::TextureFormat::Depth24PlusStencil8,
6029            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6030            view_formats: &[],
6031        });
6032        self.depth_stencil_view = self
6033            .depth_stencil_tex
6034            .create_view(&wgpu::TextureViewDescriptor::default());
6035    }
6036
6037    fn active_surface_msaa_samples(&self) -> u32 {
6038        if self.working_space {
6039            self.working_space_msaa_samples
6040        } else {
6041            self.msaa_samples
6042        }
6043    }
6044
6045    fn layer_target_format(&self) -> wgpu::TextureFormat {
6046        if self.working_space {
6047            wgpu::TextureFormat::Rgba16Float
6048        } else {
6049            self.output_format
6050        }
6051    }
6052
6053    fn get_or_create_layer(
6054        &mut self,
6055        layer_id: u32,
6056        width: u32,
6057        height: u32,
6058        rect: repose_core::Rect,
6059        transient: bool,
6060    ) -> bool {
6061        if width == 0
6062            || height == 0
6063            || width > self.device.limits().max_texture_dimension_2d
6064            || height > self.device.limits().max_texture_dimension_2d
6065        {
6066            return false;
6067        }
6068        let needs_alloc = match self.layer_pool.get(&layer_id) {
6069            Some(lt) => {
6070                lt.width != width || lt.height != height || lt.format != self.layer_target_format()
6071            }
6072            None => true,
6073        };
6074        if !needs_alloc {
6075            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
6076                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
6077            }
6078            return true;
6079        }
6080        let color_bytes =
6081            texture_storage_bytes(self.layer_target_format(), width.max(1), height.max(1))
6082                .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6083        let depth_bytes = checked_image_bytes(width.max(1), height.max(1), 4)
6084            .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6085        let bytes = color_bytes.saturating_add(depth_bytes);
6086        let old = self.layer_pool.get(&layer_id);
6087        let old_bytes = old.map_or(0, |layer| layer.bytes);
6088        let old_transient_bytes =
6089            old.map_or(0, |layer| if layer.transient { layer.bytes } else { 0 });
6090        let transient_count = self
6091            .layer_pool
6092            .values()
6093            .filter(|layer| layer.transient)
6094            .count();
6095        let transient_count = transient_count
6096            .saturating_sub(usize::from(old.is_some_and(|layer| layer.transient)))
6097            .saturating_add(usize::from(transient));
6098        let count = self.layer_pool.len() + usize::from(!self.layer_pool.contains_key(&layer_id));
6099        let total = self
6100            .layer_bytes_total
6101            .saturating_sub(old_bytes)
6102            .saturating_add(bytes);
6103        let projected_gpu = self
6104            .gpu_bytes_total()
6105            .saturating_sub(old_bytes)
6106            .saturating_add(bytes);
6107        let projected_transient = self
6108            .transient_layer_bytes_total
6109            .saturating_sub(old_transient_bytes)
6110            .saturating_add(if transient { bytes } else { 0 });
6111        if count > MAX_GRAPHICS_LAYERS
6112            || (transient
6113                && (transient_count > MAX_TRANSIENT_GRAPHICS_LAYERS
6114                    || projected_transient > MAX_TRANSIENT_GRAPHICS_LAYER_BYTES))
6115            || bytes > MAX_GRAPHICS_LAYER_BYTES
6116            || total > MAX_GRAPHICS_LAYER_BYTES
6117            || projected_gpu > self.gpu_budget_bytes
6118        {
6119            log::warn!("graphics layer budget exhausted; layer {layer_id} skipped");
6120            if let Some(old) = self.layer_pool.remove(&layer_id) {
6121                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6122                if old.transient {
6123                    self.transient_layer_bytes_total =
6124                        self.transient_layer_bytes_total.saturating_sub(old.bytes);
6125                }
6126            }
6127            return false;
6128        }
6129        if let Some(old) = self.layer_pool.remove(&layer_id) {
6130            self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6131            if old.transient {
6132                self.transient_layer_bytes_total =
6133                    self.transient_layer_bytes_total.saturating_sub(old.bytes);
6134            }
6135        }
6136        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6137            label: Some("graphics layer"),
6138            size: wgpu::Extent3d {
6139                width: width.max(1),
6140                height: height.max(1),
6141                depth_or_array_layers: 1,
6142            },
6143            mip_level_count: 1,
6144            sample_count: 1,
6145            dimension: wgpu::TextureDimension::D2,
6146            format: self.layer_target_format(),
6147            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6148                | wgpu::TextureUsages::TEXTURE_BINDING
6149                | wgpu::TextureUsages::COPY_SRC,
6150            view_formats: &[],
6151        });
6152        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6153        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6154            label: Some("layer bind"),
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),
6164                },
6165            ],
6166        });
6167        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6168            label: Some("layer bind linear"),
6169            layout: &self.image_bind_layout_rgba,
6170            entries: &[
6171                wgpu::BindGroupEntry {
6172                    binding: 0,
6173                    resource: wgpu::BindingResource::TextureView(&view),
6174                },
6175                wgpu::BindGroupEntry {
6176                    binding: 1,
6177                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
6178                },
6179            ],
6180        });
6181        let depth_stencil_view = self.create_layer_depth_stencil(width, height);
6182        self.layer_pool.insert(
6183            layer_id,
6184            LayerTarget {
6185                texture: tex,
6186                view,
6187                bind,
6188                bind_linear,
6189                depth_stencil_view,
6190                width,
6191                height,
6192                format: self.layer_target_format(),
6193                bytes,
6194                rect_px: (rect.x, rect.y, rect.w, rect.h),
6195                transient,
6196            },
6197        );
6198        self.layer_bytes_total = self.layer_bytes_total.saturating_add(bytes);
6199        if transient {
6200            self.transient_layer_bytes_total =
6201                self.transient_layer_bytes_total.saturating_add(bytes);
6202        }
6203        true
6204    }
6205
6206    /// Allocate (or reuse) the padded scratch layer holding the horizontal half
6207    /// of a separable blur for `layer_id` in `slot`.
6208    ///
6209    /// Scratch ids are handed out from [`BLUR_SCRATCH_ID_BASE`] and remembered
6210    /// per `(layer_id, slot)` in [`Self::blur_scratch_ids`], so a stable layer
6211    /// reuses its allocation across frames while a content blur and a shadow
6212    /// on the same layer never share one. Allocation goes through the layer
6213    /// pool (as a transient layer) and therefore respects the layer budget;
6214    /// `None` means the caller should composite unblurred.
6215    fn acquire_blur_scratch(&mut self, layer_id: u32, slot: u32, pad: (f32, f32)) -> Option<u32> {
6216        let src = self.layer_pool.get(&layer_id)?;
6217        let width_f = (src.width as f32 + pad.0 * 2.0).ceil();
6218        let height_f = (src.height as f32 + pad.1 * 2.0).ceil();
6219        let max_dim = self.device.limits().max_texture_dimension_2d as f32;
6220        if !width_f.is_finite()
6221            || !height_f.is_finite()
6222            || width_f < 1.0
6223            || height_f < 1.0
6224            || width_f > max_dim
6225            || height_f > max_dim
6226        {
6227            log::warn!("blur scratch size {width_f}x{height_f} is invalid");
6228            return None;
6229        }
6230        let next = self.blur_scratch_next;
6231        let scratch_id = match self.blur_scratch_ids.get(&(layer_id, slot)) {
6232            Some(&id) => id,
6233            None => {
6234                let id = BLUR_SCRATCH_ID_BASE.wrapping_add(next);
6235                self.blur_scratch_next = next.wrapping_add(1);
6236                self.blur_scratch_ids.insert((layer_id, slot), id);
6237                id
6238            }
6239        };
6240        let allocated = self.get_or_create_layer(
6241            scratch_id,
6242            width_f as u32,
6243            height_f as u32,
6244            repose_core::Rect {
6245                x: 0.0,
6246                y: 0.0,
6247                w: width_f,
6248                h: height_f,
6249            },
6250            true,
6251        );
6252        allocated.then_some(scratch_id)
6253    }
6254
6255    /// Emit the horizontal half of a separable blur for `source_layer` into a
6256    /// padded scratch layer, and return that scratch's id so the caller can
6257    /// finish the vertical axis when it composites.
6258    ///
6259    /// The scratch pass is spliced between the pass being built and the pass the
6260    /// caller continues in, so ordering stays: everything drawn so far, the
6261    /// horizontal blur, then the vertical composite. `None` means no scratch
6262    /// could be allocated and the caller should composite without the blur.
6263    ///
6264    /// `slot` picks the scratch this blur owns, so concurrent blurs of one
6265    /// layer (content and shadow) get their own texture. The horizontal
6266    /// instance carries no tint: the scratch holds the raw blurred signal and
6267    /// the caller applies the group alpha / shadow colour on the vertical pass,
6268    /// which would otherwise square the alpha across the two axes.
6269    ///
6270    /// `alpha_only` selects the shadow kernel (alpha, premultiplied by the
6271    /// tint the caller applies vertically) over the full-RGBA kernel used by
6272    /// `Modifier::blur`.
6273    #[allow(clippy::too_many_arguments)]
6274    fn emit_separable_blur(
6275        &mut self,
6276        source_layer: u32,
6277        slot: u32,
6278        sigma: [f32; 2],
6279        pad: (f32, f32),
6280        edge_mode: u32,
6281        alpha_only: bool,
6282        shape: [f32; 4],
6283        passes: &mut Vec<Pass>,
6284        current_pass: &mut Pass,
6285        next_pass_id: &mut u64,
6286        encoder: &mut wgpu::CommandEncoder,
6287    ) -> Option<u32> {
6288        let scratch_id = self.acquire_blur_scratch(source_layer, slot, pad)?;
6289        let (scratch_w, scratch_h, source_w, source_h) = {
6290            let scratch = self.layer_pool.get(&scratch_id)?;
6291            let source = self.layer_pool.get(&source_layer)?;
6292            (
6293                scratch.width,
6294                scratch.height,
6295                source.width as f32,
6296                source.height as f32,
6297            )
6298        };
6299        // The quad covers the whole scratch, and scratch texel (x, y) samples
6300        // the source at (x - pad_x, y - pad_y). Padding texels therefore land
6301        // outside the source's 0..1, and `edge_mode` decides whether that
6302        // contributes nothing (decal, correct for a shadow) or clamps to the
6303        // edge texel (the `Modifier::blur` clamp edge treatment).
6304        let uv = [
6305            -pad.0 / source_w,
6306            -pad.1 / source_h,
6307            1.0 + pad.0 / source_w,
6308            1.0 + pad.1 / source_h,
6309        ];
6310        let ndc = [-1.0, -1.0, 2.0, 2.0];
6311        let inst = BlurInstance {
6312            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
6313            uv,
6314            color: [1.0, 1.0, 1.0, 1.0],
6315            sigma_px: sigma,
6316            fwd_mat: [1.0, 0.0, 0.0, 1.0],
6317            axis: 0,
6318            edge_mode,
6319            shape,
6320        };
6321        let cmd = match self.upload_blur_instance(&inst, encoder) {
6322            Some(off) if alpha_only => Cmd::CompositeShadow {
6323                off,
6324                cnt: 1,
6325                layer_id: source_layer,
6326            },
6327            Some(off) => Cmd::CompositeBlur {
6328                off,
6329                cnt: 1,
6330                layer_id: source_layer,
6331            },
6332            None => return None,
6333        };
6334        let target = current_pass.target;
6335        let scissor = current_pass.active_scissor;
6336        let resumed_pass_id = *next_pass_id;
6337        *next_pass_id += 1;
6338        let saved = std::mem::replace(
6339            current_pass,
6340            Pass {
6341                id: resumed_pass_id,
6342                target,
6343                initial_scissor: scissor.unwrap_or((0, 0, 1, 1)),
6344                active_scissor: scissor,
6345                clear_color: None,
6346                cmds: Vec::new(),
6347            },
6348        );
6349        passes.push(saved);
6350        let scratch_pass_id = *next_pass_id;
6351        *next_pass_id += 1;
6352        passes.push(Pass {
6353            id: scratch_pass_id,
6354            target: PassTarget::Layer(scratch_id),
6355            initial_scissor: (0, 0, scratch_w, scratch_h),
6356            active_scissor: Some((0, 0, scratch_w, scratch_h)),
6357            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
6358            cmds: vec![cmd],
6359        });
6360        Some(scratch_id)
6361    }
6362
6363    fn upload_blur_instance(
6364        &mut self,
6365        inst: &BlurInstance,
6366        encoder: &mut wgpu::CommandEncoder,
6367    ) -> Option<u64> {
6368        self.blur_ring
6369            .grow_to_fit(
6370                &self.device,
6371                encoder,
6372                std::mem::size_of::<BlurInstance>() as u64,
6373            )
6374            .ok()?;
6375        self.blur_ring
6376            .alloc_write(&self.queue, bytemuck::bytes_of(inst))
6377            .ok()
6378    }
6379    fn create_layer_depth_stencil(&self, width: u32, height: u32) -> wgpu::TextureView {
6380        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6381            label: Some("layer depth-stencil"),
6382            size: wgpu::Extent3d {
6383                width: width.max(1),
6384                height: height.max(1),
6385                depth_or_array_layers: 1,
6386            },
6387            mip_level_count: 1,
6388            sample_count: 1,
6389            dimension: wgpu::TextureDimension::D2,
6390            format: wgpu::TextureFormat::Depth24PlusStencil8,
6391            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6392            view_formats: &[],
6393        });
6394        tex.create_view(&wgpu::TextureViewDescriptor::default())
6395    }
6396
6397    fn ensure_globals_staging(&mut self, pass_count: usize) -> bool {
6398        let size = std::mem::size_of::<Globals>() as u64;
6399        let needed = match (pass_count.max(1) as u64).checked_mul(size) {
6400            Some(needed) => needed,
6401            None => return false,
6402        };
6403        if needed <= self.globals_staging_cap {
6404            return true;
6405        }
6406        let cap = needed
6407            .next_power_of_two()
6408            .min(self.device.limits().max_buffer_size);
6409        if cap < needed {
6410            return false;
6411        }
6412        self.globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
6413            label: Some("globals staging"),
6414            size: cap,
6415            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
6416            mapped_at_creation: false,
6417        });
6418        self.globals_staging_cap = cap;
6419        true
6420    }
6421
6422    fn reset_atlas_storage(&mut self) {
6423        self.atlas_mask = init_atlas_mask(&self.device);
6424        self.atlas_color = init_atlas_color(&self.device);
6425        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6426            label: Some("atlas mask bind"),
6427            layout: &self.text_bind_layout,
6428            entries: &[
6429                wgpu::BindGroupEntry {
6430                    binding: 0,
6431                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6432                },
6433                wgpu::BindGroupEntry {
6434                    binding: 1,
6435                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6436                },
6437            ],
6438        });
6439        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6440            label: Some("atlas color bind"),
6441            layout: &self.text_bind_layout,
6442            entries: &[
6443                wgpu::BindGroupEntry {
6444                    binding: 0,
6445                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6446                },
6447                wgpu::BindGroupEntry {
6448                    binding: 1,
6449                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6450                },
6451            ],
6452        });
6453        self.atlas_mask_pending = AtlasPending::default();
6454        self.atlas_color_pending = AtlasPending::default();
6455        self.atlas_mask_deferred.clear();
6456        self.atlas_color_deferred.clear();
6457    }
6458
6459    /// Apply atlas growth and compaction scheduled during the previous frame.
6460    /// Called from `begin_frame`, before any glyph is batched: both operations
6461    /// re-pack the atlas, so running them mid-frame would leave already-batched
6462    /// instances pointing at the old packing.
6463    fn apply_atlas_pending(&mut self) {
6464        if self.atlas_mask_pending.grow {
6465            self.atlas_mask_pending.grow = false;
6466            let size = (self.atlas_mask.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6467            self.repack_mask(size);
6468        }
6469        if self.atlas_color_pending.grow {
6470            self.atlas_color_pending.grow = false;
6471            let size = (self.atlas_color.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6472            self.repack_color(size);
6473        }
6474        if self.frame_index.wrapping_sub(self.atlas_last_compact_frame)
6475            < ATLAS_COMPACT_MIN_FRAME_INTERVAL
6476        {
6477            return;
6478        }
6479        // Stay armed when a compaction finds nothing evictable, so the deferred
6480        // glyphs it would rescue get another chance next frame.
6481        if self.atlas_mask_pending.compact && self.compact_atlas_mask() {
6482            self.atlas_mask_pending.compact = false;
6483        }
6484        if self.atlas_color_pending.compact && self.compact_atlas_color() {
6485            self.atlas_color_pending.compact = false;
6486        }
6487    }
6488
6489    /// Drop the least recently used mask glyphs and re-pack at the same size.
6490    /// A shelf packer cannot hand back an individual cell, so reclaiming space
6491    /// means evicting cold entries and rebuilding around the warm ones. Returns
6492    /// false when there was nothing to evict.
6493    fn compact_atlas_mask(&mut self) -> bool {
6494        let target = self.atlas_mask.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6495        if !evict_atlas_lru(
6496            &mut self.atlas_mask.map,
6497            &mut self.atlas_mask.used_area,
6498            self.frame_index,
6499            target,
6500        ) {
6501            return false;
6502        }
6503        let size = self.atlas_mask.size;
6504        self.repack_mask(size);
6505        self.atlas_last_compact_frame = self.frame_index;
6506        true
6507    }
6508
6509    fn compact_atlas_color(&mut self) -> bool {
6510        let target = self.atlas_color.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6511        if !evict_atlas_lru(
6512            &mut self.atlas_color.map,
6513            &mut self.atlas_color.used_area,
6514            self.frame_index,
6515            target,
6516        ) {
6517            return false;
6518        }
6519        let size = self.atlas_color.size;
6520        self.repack_color(size);
6521        self.atlas_last_compact_frame = self.frame_index;
6522        true
6523    }
6524
6525    /// Re-pack the mask atlas into a `new_size` texture and re-upload every
6526    /// surviving entry, preserving its recency so a repack does not make warm
6527    /// glyphs look cold.
6528    fn repack_mask(&mut self, new_size: u32) {
6529        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6530            label: Some("glyph atlas A8 (repacked)"),
6531            size: wgpu::Extent3d {
6532                width: new_size,
6533                height: new_size,
6534                depth_or_array_layers: 1,
6535            },
6536            mip_level_count: 1,
6537            sample_count: 1,
6538            dimension: wgpu::TextureDimension::D2,
6539            format: wgpu::TextureFormat::R8Unorm,
6540            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6541            view_formats: &[],
6542        });
6543        self.atlas_mask.tex = tex;
6544        self.atlas_mask.view = self
6545            .atlas_mask
6546            .tex
6547            .create_view(&wgpu::TextureViewDescriptor::default());
6548        self.atlas_mask.size = new_size;
6549        self.atlas_mask.next_x = 1;
6550        self.atlas_mask.next_y = 1;
6551        self.atlas_mask.row_h = 0;
6552        self.atlas_mask.used_area = 0;
6553        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6554            label: Some("atlas mask bind"),
6555            layout: &self.text_bind_layout,
6556            entries: &[
6557                wgpu::BindGroupEntry {
6558                    binding: 0,
6559                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6560                },
6561                wgpu::BindGroupEntry {
6562                    binding: 1,
6563                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6564                },
6565            ],
6566        });
6567        let survivors: Vec<_> = self.atlas_mask.map.drain().collect();
6568        self.atlas_mask_deferred.clear();
6569        for (key, previous) in survivors {
6570            let _ = self.upload_glyph_mask(key.clone(), f32::from_bits(key.font_size_bits));
6571            if let Some(info) = self.atlas_mask.map.get_mut(&key) {
6572                info.last_touch = previous.last_touch;
6573                info.last_used_frame = previous.last_used_frame;
6574            }
6575        }
6576    }
6577
6578    /// Re-pack the color atlas into a `new_size` texture. See [`Self::repack_mask`].
6579    fn repack_color(&mut self, new_size: u32) {
6580        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6581            label: Some("glyph atlas RGBA (repacked)"),
6582            size: wgpu::Extent3d {
6583                width: new_size,
6584                height: new_size,
6585                depth_or_array_layers: 1,
6586            },
6587            mip_level_count: 1,
6588            sample_count: 1,
6589            dimension: wgpu::TextureDimension::D2,
6590            format: wgpu::TextureFormat::Rgba8UnormSrgb,
6591            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6592            view_formats: &[],
6593        });
6594        self.atlas_color.tex = tex;
6595        self.atlas_color.view = self
6596            .atlas_color
6597            .tex
6598            .create_view(&wgpu::TextureViewDescriptor::default());
6599        self.atlas_color.size = new_size;
6600        self.atlas_color.next_x = 1;
6601        self.atlas_color.next_y = 1;
6602        self.atlas_color.row_h = 0;
6603        self.atlas_color.used_area = 0;
6604        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6605            label: Some("atlas color bind"),
6606            layout: &self.text_bind_layout,
6607            entries: &[
6608                wgpu::BindGroupEntry {
6609                    binding: 0,
6610                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6611                },
6612                wgpu::BindGroupEntry {
6613                    binding: 1,
6614                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6615                },
6616            ],
6617        });
6618        let survivors: Vec<_> = self.atlas_color.map.drain().collect();
6619        self.atlas_color_deferred.clear();
6620        for (key, previous) in survivors {
6621            let _ = self.upload_glyph_color(key.clone(), f32::from_bits(key.font_size_bits));
6622            if let Some(info) = self.atlas_color.map.get_mut(&key) {
6623                info.last_touch = previous.last_touch;
6624                info.last_used_frame = previous.last_used_frame;
6625            }
6626        }
6627    }
6628
6629    fn sync_text_caches(&mut self) {
6630        let generation = repose_text::font_generation();
6631        if generation == self.atlas_generation {
6632            return;
6633        }
6634        self.atlas_generation = generation;
6635        self.reset_atlas_storage();
6636        self.atlas_mask_failures.clear();
6637        self.atlas_color_failures.clear();
6638        self.glyph_outline_cache.clear();
6639        self.legacy_shaped_text_cache.clear();
6640        self.slug_draw_cache.clear();
6641        self.slug_draw_cache_bytes_total = 0;
6642        self.slug_cache = slug::GlyphSlugCache::new();
6643    }
6644
6645    fn shape_text_cached(
6646        &mut self,
6647        text: &Arc<str>,
6648        px: f32,
6649        line_height_ratio: f32,
6650        font_family: Option<&'static str>,
6651        font_weight: u16,
6652        font_style: u8,
6653        letter_spacing: f32,
6654        variation: Option<&Arc<str>>,
6655    ) -> Arc<repose_text::SharedShapedText> {
6656        repose_text::shape_line_cached(
6657            text.as_ref(),
6658            px,
6659            line_height_ratio,
6660            font_family,
6661            font_weight,
6662            font_style,
6663            letter_spacing,
6664            variation.map(AsRef::as_ref),
6665        )
6666    }
6667
6668    fn legacy_shape_text_cached(
6669        &mut self,
6670        text: &Arc<str>,
6671        px: f32,
6672        line_height_ratio: f32,
6673        font_family: Option<&'static str>,
6674        font_weight: u16,
6675        font_style: u8,
6676        letter_spacing: f32,
6677        variation: Option<&Arc<str>>,
6678    ) -> Arc<[repose_text::ShapedGlyph]> {
6679        let key = TextShapeKey {
6680            text: text.clone(),
6681            px_bits: px.to_bits(),
6682            line_height_bits: line_height_ratio.to_bits(),
6683            font_family,
6684            font_weight,
6685            font_style,
6686            letter_spacing_bits: letter_spacing.to_bits(),
6687            variation: variation.cloned(),
6688            font_generation: self.atlas_generation,
6689        };
6690        if let Some(entry) = self.legacy_shaped_text_cache.get_mut(&key) {
6691            entry.last_used_frame = self.frame_index;
6692            return entry.glyphs.clone();
6693        }
6694        let glyphs: Arc<[repose_text::ShapedGlyph]> = Arc::from(repose_text::shape_line(
6695            text.as_ref(),
6696            px,
6697            line_height_ratio,
6698            font_family,
6699            font_weight,
6700            font_style,
6701            letter_spacing,
6702            variation.map(AsRef::as_ref),
6703        ));
6704        self.legacy_shaped_text_cache.insert(
6705            key,
6706            TextShapeCacheEntry {
6707                glyphs: glyphs.clone(),
6708                last_used_frame: self.frame_index,
6709            },
6710        );
6711        while self.legacy_shaped_text_cache.len() > MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES {
6712            let Some(victim) = self
6713                .legacy_shaped_text_cache
6714                .iter()
6715                .min_by_key(|(_, entry)| entry.last_used_frame)
6716                .map(|(key, _)| key.clone())
6717            else {
6718                break;
6719            };
6720            self.legacy_shaped_text_cache.remove(&victim);
6721        }
6722        glyphs
6723    }
6724
6725    fn slug_draw_key(
6726        cache_key: &repose_text::CacheKey,
6727        fill: bool,
6728        stroke_key: Option<&slug::StrokeTessKey>,
6729        transform: &Transform,
6730        origin: (f32, f32),
6731        px: f32,
6732        target: (f32, f32),
6733        color: [f32; 4],
6734    ) -> SlugDrawKey {
6735        let linear = transform.linear();
6736        SlugDrawKey {
6737            cache_key: cache_key.clone(),
6738            fill,
6739            stroke: stroke_key.cloned(),
6740            transform: [
6741                linear[0].to_bits(),
6742                linear[1].to_bits(),
6743                linear[2].to_bits(),
6744                linear[3].to_bits(),
6745                transform.scale_x.to_bits(),
6746                transform.scale_y.to_bits(),
6747                transform.translate_x.to_bits(),
6748                transform.translate_y.to_bits(),
6749                transform.rotate.to_bits(),
6750                transform.shear_x.to_bits(),
6751                transform.shear_y.to_bits(),
6752            ],
6753            origin: [origin.0.to_bits(), origin.1.to_bits()],
6754            px_bits: px.to_bits(),
6755            target: [target.0.to_bits(), target.1.to_bits()],
6756            color: color.map(f32::to_bits),
6757        }
6758    }
6759
6760    fn evict_slug_draw_cache(&mut self) -> bool {
6761        let victim = self
6762            .slug_draw_cache
6763            .iter()
6764            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
6765            .min_by_key(|(_, entry)| entry.last_touch_frame)
6766            .map(|(key, _)| key.clone());
6767        let Some(victim) = victim else {
6768            return false;
6769        };
6770        let Some(entry) = self.slug_draw_cache.remove(&victim) else {
6771            return false;
6772        };
6773        self.slug_draw_cache_bytes_total =
6774            self.slug_draw_cache_bytes_total.saturating_sub(entry.bytes);
6775        true
6776    }
6777
6778    fn reserve_slug_draw_cache(&mut self, bytes: u64) -> bool {
6779        while self.slug_draw_cache.len() >= MAX_SLUG_DRAW_CACHE_ENTRIES
6780            || self.slug_draw_cache_bytes_total.saturating_add(bytes) > MAX_SLUG_DRAW_CACHE_BYTES
6781        {
6782            if !self.evict_slug_draw_cache() {
6783                return false;
6784            }
6785        }
6786        true
6787    }
6788
6789    fn get_cached_slug_vertices(&mut self, key: &SlugDrawKey) -> Option<Arc<[slug::TessVertex]>> {
6790        let touch = self.next_cache_touch();
6791        self.slug_draw_cache.get_mut(key).map(|entry| {
6792            entry.last_used_frame = self.frame_index;
6793            entry.last_touch_frame = touch;
6794            entry.vertices.clone()
6795        })
6796    }
6797
6798    fn cached_slug_vertices(
6799        &mut self,
6800        key: SlugDrawKey,
6801        vertices: Vec<slug::TessVertex>,
6802    ) -> Arc<[slug::TessVertex]> {
6803        let touch = self.next_cache_touch();
6804        if let Some(entry) = self.slug_draw_cache.get_mut(&key) {
6805            entry.last_used_frame = self.frame_index;
6806            entry.last_touch_frame = touch;
6807            return entry.vertices.clone();
6808        }
6809        let bytes = slug_draw_cache_entry_bytes(vertices.len());
6810        if bytes > MAX_SLUG_DRAW_CACHE_BYTES || !self.reserve_slug_draw_cache(bytes) {
6811            return Arc::from(vertices);
6812        }
6813        let touch = self.next_cache_touch();
6814        let vertices: Arc<[slug::TessVertex]> = Arc::from(vertices);
6815        self.slug_draw_cache.insert(
6816            key,
6817            SlugDrawCacheEntry {
6818                vertices: vertices.clone(),
6819                bytes,
6820                last_used_frame: self.frame_index,
6821                last_touch_frame: touch,
6822            },
6823        );
6824        self.slug_draw_cache_bytes_total = self.slug_draw_cache_bytes_total.saturating_add(bytes);
6825        vertices
6826    }
6827
6828    fn cached_glyph_outline(
6829        &mut self,
6830        cache_key: &repose_text::CacheKey,
6831    ) -> Option<Arc<[repose_text::Command]>> {
6832        let touch = self.next_cache_touch();
6833        if let Some(entry) = self.glyph_outline_cache.get_mut(cache_key) {
6834            entry.last_touch = touch;
6835            return entry.commands.clone();
6836        }
6837        let commands = repose_text::extract_outline_commands(cache_key).map(Arc::from);
6838        if self.glyph_outline_cache.len() >= MAX_GLYPH_OUTLINE_CACHE_ENTRIES {
6839            self.trim_glyph_outline_cache();
6840        }
6841        self.glyph_outline_cache.insert(
6842            cache_key.clone(),
6843            GlyphOutlineEntry {
6844                commands: commands.clone(),
6845                last_touch: touch,
6846            },
6847        );
6848        commands
6849    }
6850
6851    /// Drop the coldest outlines so a working set larger than the cap degrades
6852    /// into steady churn instead of a full rebuild on every miss.
6853    fn trim_glyph_outline_cache(&mut self) {
6854        let target = (MAX_GLYPH_OUTLINE_CACHE_ENTRIES / ATLAS_COMPACT_KEEP_DEN as usize)
6855            * ATLAS_COMPACT_KEEP_NUM as usize;
6856        let mut victims: Vec<(u64, repose_text::CacheKey)> = self
6857            .glyph_outline_cache
6858            .iter()
6859            .map(|(key, entry)| (entry.last_touch, key.clone()))
6860            .collect();
6861        victims.sort_unstable_by_key(|victim| victim.0);
6862        for (_, key) in victims {
6863            if self.glyph_outline_cache.len() <= target {
6864                break;
6865            }
6866            self.glyph_outline_cache.remove(&key);
6867        }
6868    }
6869
6870    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
6871        self.atlas_mask_bind.clone()
6872    }
6873
6874    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
6875        self.atlas_color_bind.clone()
6876    }
6877
6878    fn mark_atlas_mask_failure(&mut self, key: repose_text::CacheKey) {
6879        if self.atlas_mask_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6880            self.atlas_mask_failures.clear();
6881        }
6882        self.atlas_mask_failures.insert(key);
6883    }
6884
6885    fn mark_atlas_color_failure(&mut self, key: repose_text::CacheKey) {
6886        if self.atlas_color_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6887            self.atlas_color_failures.clear();
6888        }
6889        self.atlas_color_failures.insert(key);
6890    }
6891
6892    fn upload_glyph_mask(
6893        &mut self,
6894        cache_key: repose_text::CacheKey,
6895        px: f32,
6896    ) -> Option<GlyphInfo> {
6897        let px = quantize_glyph_px(px);
6898        let keyp = repose_text::CacheKey {
6899            font_size_bits: px.to_bits(),
6900            ..cache_key
6901        };
6902        let touch = self.next_cache_touch();
6903        if let Some(info) = self.atlas_mask.map.get_mut(&keyp) {
6904            info.last_touch = touch;
6905            info.last_used_frame = self.frame_index;
6906            return Some(*info);
6907        }
6908        if self.atlas_mask_failures.contains(&keyp) || self.atlas_mask_deferred.contains(&keyp) {
6909            return None;
6910        }
6911
6912        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
6913            self.mark_atlas_mask_failure(keyp);
6914            return None;
6915        };
6916        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
6917            self.mark_atlas_mask_failure(keyp);
6918            return None;
6919        }
6920        let Some(coverage) = swash_to_a8_coverage(gb.content, &gb.data) else {
6921            self.mark_atlas_mask_failure(keyp);
6922            return None;
6923        };
6924
6925        let w = gb.w.max(1);
6926        let h = gb.h.max(1);
6927        let expected = checked_image_bytes_usize(w, h, 1).ok()?;
6928        if coverage.len() < expected {
6929            self.mark_atlas_mask_failure(keyp);
6930            return None;
6931        }
6932        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
6933            self.mark_atlas_mask_failure(keyp);
6934            return None;
6935        }
6936        if !self.alloc_or_schedule_mask(w, h) {
6937            defer_atlas_glyph(&mut self.atlas_mask_deferred, keyp);
6938            return None;
6939        }
6940        let x = self.atlas_mask.next_x;
6941        let y = self.atlas_mask.next_y;
6942        self.atlas_mask.next_x += w + 1;
6943        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
6944
6945        let layout = wgpu::TexelCopyBufferLayout {
6946            offset: 0,
6947            bytes_per_row: Some(w),
6948            rows_per_image: Some(h),
6949        };
6950        let size = wgpu::Extent3d {
6951            width: w,
6952            height: h,
6953            depth_or_array_layers: 1,
6954        };
6955        self.queue.write_texture(
6956            wgpu::TexelCopyTextureInfoBase {
6957                texture: &self.atlas_mask.tex,
6958                mip_level: 0,
6959                origin: wgpu::Origin3d { x, y, z: 0 },
6960                aspect: wgpu::TextureAspect::All,
6961            },
6962            &coverage,
6963            layout,
6964            size,
6965        );
6966
6967        let info = GlyphInfo {
6968            u0: x as f32 / self.atlas_mask.size as f32,
6969            v0: y as f32 / self.atlas_mask.size as f32,
6970            u1: (x + w) as f32 / self.atlas_mask.size as f32,
6971            v1: (y + h) as f32 / self.atlas_mask.size as f32,
6972            w: w as f32,
6973            h: h as f32,
6974            last_touch: touch,
6975            last_used_frame: self.frame_index,
6976        };
6977        self.atlas_mask.used_area = self
6978            .atlas_mask
6979            .used_area
6980            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
6981        self.atlas_mask.map.insert(keyp, info);
6982        Some(info)
6983    }
6984
6985    fn upload_glyph_color(
6986        &mut self,
6987        cache_key: repose_text::CacheKey,
6988        px: f32,
6989    ) -> Option<GlyphInfo> {
6990        let px = quantize_glyph_px(px);
6991        let keyp = repose_text::CacheKey {
6992            font_size_bits: px.to_bits(),
6993            ..cache_key
6994        };
6995        let touch = self.next_cache_touch();
6996        if let Some(info) = self.atlas_color.map.get_mut(&keyp) {
6997            info.last_touch = touch;
6998            info.last_used_frame = self.frame_index;
6999            return Some(*info);
7000        }
7001        if self.atlas_color_failures.contains(&keyp) || self.atlas_color_deferred.contains(&keyp) {
7002            return None;
7003        }
7004        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7005            self.mark_atlas_color_failure(keyp);
7006            return None;
7007        };
7008        if !matches!(gb.content, repose_text::SwashContent::Color) {
7009            self.mark_atlas_color_failure(keyp);
7010            return None;
7011        }
7012        let w = gb.w.max(1);
7013        let h = gb.h.max(1);
7014        let expected = checked_image_bytes_usize(w, h, 4).ok()?;
7015        if gb.data.len() < expected {
7016            self.mark_atlas_color_failure(keyp);
7017            return None;
7018        }
7019        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7020            self.mark_atlas_color_failure(keyp);
7021            return None;
7022        }
7023        if !self.alloc_or_schedule_color(w, h) {
7024            defer_atlas_glyph(&mut self.atlas_color_deferred, keyp);
7025            return None;
7026        }
7027        let x = self.atlas_color.next_x;
7028        let y = self.atlas_color.next_y;
7029        self.atlas_color.next_x += w + 1;
7030        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
7031
7032        let layout = wgpu::TexelCopyBufferLayout {
7033            offset: 0,
7034            bytes_per_row: Some(w * 4),
7035            rows_per_image: Some(h),
7036        };
7037        let size = wgpu::Extent3d {
7038            width: w,
7039            height: h,
7040            depth_or_array_layers: 1,
7041        };
7042        self.queue.write_texture(
7043            wgpu::TexelCopyTextureInfoBase {
7044                texture: &self.atlas_color.tex,
7045                mip_level: 0,
7046                origin: wgpu::Origin3d { x, y, z: 0 },
7047                aspect: wgpu::TextureAspect::All,
7048            },
7049            &gb.data,
7050            layout,
7051            size,
7052        );
7053        let info = GlyphInfo {
7054            u0: x as f32 / self.atlas_color.size as f32,
7055            v0: y as f32 / self.atlas_color.size as f32,
7056            u1: (x + w) as f32 / self.atlas_color.size as f32,
7057            v1: (y + h) as f32 / self.atlas_color.size as f32,
7058            w: w as f32,
7059            h: h as f32,
7060            last_touch: touch,
7061            last_used_frame: self.frame_index,
7062        };
7063        self.atlas_color.used_area = self
7064            .atlas_color
7065            .used_area
7066            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7067        self.atlas_color.map.insert(keyp, info);
7068        Some(info)
7069    }
7070
7071    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
7072        let Some(x_end) = self
7073            .atlas_mask
7074            .next_x
7075            .checked_add(w)
7076            .and_then(|v| v.checked_add(1))
7077        else {
7078            return false;
7079        };
7080        if x_end >= self.atlas_mask.size {
7081            self.atlas_mask.next_x = 1;
7082            let Some(next_y) = self
7083                .atlas_mask
7084                .next_y
7085                .checked_add(self.atlas_mask.row_h)
7086                .and_then(|v| v.checked_add(1))
7087            else {
7088                return false;
7089            };
7090            self.atlas_mask.next_y = next_y;
7091            self.atlas_mask.row_h = 0;
7092        }
7093        let Some(y_end) = self
7094            .atlas_mask
7095            .next_y
7096            .checked_add(h)
7097            .and_then(|v| v.checked_add(1))
7098        else {
7099            return false;
7100        };
7101        y_end < self.atlas_mask.size
7102    }
7103
7104    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
7105        let Some(x_end) = self
7106            .atlas_color
7107            .next_x
7108            .checked_add(w)
7109            .and_then(|v| v.checked_add(1))
7110        else {
7111            return false;
7112        };
7113        if x_end >= self.atlas_color.size {
7114            self.atlas_color.next_x = 1;
7115            let Some(next_y) = self
7116                .atlas_color
7117                .next_y
7118                .checked_add(self.atlas_color.row_h)
7119                .and_then(|v| v.checked_add(1))
7120            else {
7121                return false;
7122            };
7123            self.atlas_color.next_y = next_y;
7124            self.atlas_color.row_h = 0;
7125        }
7126        let Some(y_end) = self
7127            .atlas_color
7128            .next_y
7129            .checked_add(h)
7130            .and_then(|v| v.checked_add(1))
7131        else {
7132            return false;
7133        };
7134        y_end < self.atlas_color.size
7135    }
7136
7137    /// Place a `w` x `h` cell in the mask atlas, or schedule the work needed to
7138    /// make room. Growth and eviction both re-pack the atlas, which would move
7139    /// glyphs already batched into the frame in flight, so they are deferred to
7140    /// the next `begin_frame`; the caller treats a `false` as "skip this glyph".
7141    fn alloc_or_schedule_mask(&mut self, w: u32, h: u32) -> bool {
7142        if self.atlas_mask.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7143            || self
7144                .atlas_mask
7145                .used_area
7146                .saturating_add(u64::from(w) * u64::from(h))
7147                >= self.atlas_mask.size as u64 * self.atlas_mask.size as u64
7148        {
7149            self.atlas_mask_pending.compact = true;
7150            return false;
7151        }
7152        if self.alloc_space_mask(w, h) {
7153            return true;
7154        }
7155        if self.atlas_mask.size < MAX_GLYPH_ATLAS_SIZE {
7156            self.atlas_mask_pending.grow = true;
7157        } else {
7158            self.atlas_mask_pending.compact = true;
7159        }
7160        false
7161    }
7162
7163    /// See [`Self::alloc_or_schedule_mask`].
7164    fn alloc_or_schedule_color(&mut self, w: u32, h: u32) -> bool {
7165        if self.atlas_color.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7166            || self
7167                .atlas_color
7168                .used_area
7169                .saturating_add(u64::from(w) * u64::from(h))
7170                >= self.atlas_color.size as u64 * self.atlas_color.size as u64
7171        {
7172            self.atlas_color_pending.compact = true;
7173            return false;
7174        }
7175        if self.alloc_space_color(w, h) {
7176            return true;
7177        }
7178        if self.atlas_color.size < MAX_GLYPH_ATLAS_SIZE {
7179            self.atlas_color_pending.grow = true;
7180        } else {
7181            self.atlas_color_pending.compact = true;
7182        }
7183        false
7184    }
7185}
7186
7187/// Pack the shape brush enums into the single `bits` word the shaders read, so
7188/// they cost one flat varying instead of three locations.
7189fn pack_shape_bits(brush_type: u32, grad_kind: u32, tile_mode: u32, cap: u32) -> u32 {
7190    debug_assert!(brush_type < 4 && grad_kind < 4 && tile_mode < 4 && cap < 4);
7191    (brush_type & 3) | ((grad_kind & 3) << 2) | ((tile_mode & 3) << 4) | ((cap & 3) << 6)
7192}
7193
7194/// Packed brush fields shared by the shape instances (border, ellipse,
7195/// ellipse border, arc). Gradient endpoints are shape-local px; the shaders
7196/// recenter `(0,0)` at the shape top-left. Radial packs `center` into
7197/// `grad_p0` and `radius` into `grad_p1.x`; sweep packs `center` into
7198/// `grad_p0`.
7199///
7200/// `rect` is the shape's scene-space bounds and `transform` the accumulated
7201/// scene transform.
7202/// Endpoints are converted from shape-local px through the inverse linear
7203/// part so rotation and uniform scale cancel against the shader's
7204/// un-rotation. Non-uniform scale and shear distort the gradient the same
7205/// way they distort the shape (the shader un-rotates but cannot un-scale
7206/// pixels).
7207fn brush_to_shape_fields(
7208    brush: &Brush,
7209    rect: &repose_core::Rect,
7210    transform: &Transform,
7211) -> (u32, u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2], u32) {
7212    let to_local = |p: Vec2| {
7213        let m = transform.linear();
7214        let det = m[0] * m[3] - m[1] * m[2];
7215        if det.abs() < 1e-12 {
7216            return [p.x, p.y];
7217        }
7218        [
7219            (m[3] * p.x - m[1] * p.y) / det,
7220            (-m[2] * p.x + m[0] * p.y) / det,
7221        ]
7222    };
7223    match brush {
7224        Brush::Solid(c) => (
7225            0u32,
7226            0u32,
7227            c.to_linear(),
7228            [0.0; 4],
7229            [0.0; 2],
7230            [0.0; 2],
7231            0u32,
7232        ),
7233        Brush::Linear {
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(*start),
7244            to_local(*end),
7245            0u32,
7246        ),
7247        Brush::LinearNormalized {
7248            start,
7249            end,
7250            start_color,
7251            end_color,
7252        } => (
7253            1u32,
7254            0u32,
7255            start_color.to_linear(),
7256            end_color.to_linear(),
7257            to_local(Vec2 {
7258                x: start.x * rect.w,
7259                y: start.y * rect.h,
7260            }),
7261            to_local(Vec2 {
7262                x: end.x * rect.w,
7263                y: end.y * rect.h,
7264            }),
7265            0u32,
7266        ),
7267        Brush::Radial {
7268            center,
7269            radius,
7270            start_color,
7271            end_color,
7272        } => (
7273            1u32,
7274            1u32,
7275            start_color.to_linear(),
7276            end_color.to_linear(),
7277            to_local(*center),
7278            [radius.max(0.0), 0.0],
7279            0u32,
7280        ),
7281        Brush::Sweep {
7282            center,
7283            start_color,
7284            end_color,
7285        } => (
7286            1u32,
7287            2u32,
7288            start_color.to_linear(),
7289            end_color.to_linear(),
7290            to_local(*center),
7291            [0.0, 0.0],
7292            0u32,
7293        ),
7294        _ => (0u32, 0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2], 0u32),
7295    }
7296}
7297
7298fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
7299    match brush {
7300        Brush::Solid(c) => (
7301            0u32,
7302            c.to_linear(),
7303            [0.0, 0.0, 0.0, 0.0],
7304            [0.0, 0.0],
7305            [0.0, 1.0],
7306        ),
7307        Brush::Linear {
7308            start,
7309            end,
7310            start_color,
7311            end_color,
7312        } => (
7313            1u32,
7314            start_color.to_linear(),
7315            end_color.to_linear(),
7316            [start.x, start.y],
7317            [end.x, end.y],
7318        ),
7319        Brush::LinearNormalized {
7320            start,
7321            end,
7322            start_color,
7323            end_color,
7324        } => (
7325            1u32,
7326            start_color.to_linear(),
7327            end_color.to_linear(),
7328            [start.x, start.y],
7329            [end.x, end.y],
7330        ),
7331        Brush::Radial { start_color, .. } => (
7332            0u32,
7333            start_color.to_linear(),
7334            [0.0, 0.0, 0.0, 0.0],
7335            [0.0, 0.0],
7336            [0.0, 1.0],
7337        ),
7338        Brush::Sweep { start_color, .. } => (
7339            0u32,
7340            start_color.to_linear(),
7341            [0.0, 0.0, 0.0, 0.0],
7342            [0.0, 0.0],
7343            [0.0, 1.0],
7344        ),
7345        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
7346    }
7347}
7348
7349/// Fallback color when a [`Brush`] reaches a solid-only path (glyph atlas
7350/// uploads for gradient text). Uses the gradient's start color.
7351#[allow(dead_code)]
7352fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
7353    match brush {
7354        Brush::Solid(c) => c.to_linear(),
7355        Brush::Linear { start_color, .. } => start_color.to_linear(),
7356        Brush::LinearNormalized { start_color, .. } => start_color.to_linear(),
7357        Brush::Radial { start_color, .. } => start_color.to_linear(),
7358        Brush::Sweep { start_color, .. } => start_color.to_linear(),
7359        _ => [0.0; 4],
7360    }
7361}
7362
7363fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
7364    let size = 1024u32;
7365    let tex = device.create_texture(&wgpu::TextureDescriptor {
7366        label: Some("glyph atlas A8"),
7367        size: wgpu::Extent3d {
7368            width: size,
7369            height: size,
7370            depth_or_array_layers: 1,
7371        },
7372        mip_level_count: 1,
7373        sample_count: 1,
7374        dimension: wgpu::TextureDimension::D2,
7375        format: wgpu::TextureFormat::R8Unorm,
7376        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7377        view_formats: &[],
7378    });
7379    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7380    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7381        label: Some("glyph atlas sampler A8"),
7382        address_mode_u: wgpu::AddressMode::ClampToEdge,
7383        address_mode_v: wgpu::AddressMode::ClampToEdge,
7384        address_mode_w: wgpu::AddressMode::ClampToEdge,
7385        mag_filter: wgpu::FilterMode::Linear,
7386        min_filter: wgpu::FilterMode::Linear,
7387        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7388        ..Default::default()
7389    });
7390
7391    AtlasA8 {
7392        tex,
7393        view,
7394        sampler,
7395        size,
7396        next_x: 1,
7397        next_y: 1,
7398        row_h: 0,
7399        map: HashMap::new(),
7400        used_area: 0,
7401    }
7402}
7403
7404fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
7405    let size = 1024u32;
7406    let tex = device.create_texture(&wgpu::TextureDescriptor {
7407        label: Some("glyph atlas RGBA"),
7408        size: wgpu::Extent3d {
7409            width: size,
7410            height: size,
7411            depth_or_array_layers: 1,
7412        },
7413        mip_level_count: 1,
7414        sample_count: 1,
7415        dimension: wgpu::TextureDimension::D2,
7416        format: wgpu::TextureFormat::Rgba8UnormSrgb,
7417        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7418        view_formats: &[],
7419    });
7420    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7421    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7422        label: Some("glyph atlas sampler RGBA"),
7423        address_mode_u: wgpu::AddressMode::ClampToEdge,
7424        address_mode_v: wgpu::AddressMode::ClampToEdge,
7425        address_mode_w: wgpu::AddressMode::ClampToEdge,
7426        mag_filter: wgpu::FilterMode::Linear,
7427        min_filter: wgpu::FilterMode::Linear,
7428        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7429        ..Default::default()
7430    });
7431    AtlasRGBA {
7432        tex,
7433        view,
7434        sampler,
7435        size,
7436        next_x: 1,
7437        next_y: 1,
7438        row_h: 0,
7439        map: HashMap::new(),
7440        used_area: 0,
7441    }
7442}
7443
7444#[cfg(feature = "winit-surface")]
7445impl WgpuSurfaceBackend {
7446    /// Drop the surface, keeping device/queue/pipelines. Releases the old
7447    /// native-window binding. Call `recreate_surface` to present again.
7448    pub fn take_surface(&mut self) -> Option<wgpu::Surface<'static>> {
7449        self.surface.take()
7450    }
7451
7452    /// Create a fresh surface for `window` on the retained instance and
7453    /// configure it. Recovers from `CurrentSurfaceTexture::Lost`, where
7454    /// reconfiguring the old surface object cannot help.
7455    pub fn recreate_surface(&mut self, window: &Arc<winit::window::Window>) -> anyhow::Result<()> {
7456        self.window = Some(window.clone());
7457        let size = window.inner_size();
7458        if size.width == 0 || size.height == 0 {
7459            self.surface = None;
7460            self.pending_reconfigure = false;
7461            self.renderer.resize(0, 0);
7462            anyhow::bail!("window has zero size; surface recreation deferred");
7463        }
7464        let Some(instance) = self.instance.as_ref() else {
7465            anyhow::bail!("no wgpu instance retained; cannot recreate surface")
7466        };
7467        let Some(config) = self.surface_config.as_mut() else {
7468            anyhow::bail!("no surface config retained; cannot recreate surface")
7469        };
7470        let (width, height) = clamp_output_size(
7471            size.width,
7472            size.height,
7473            self.renderer.device.limits().max_texture_dimension_2d,
7474        );
7475        config.width = width;
7476        config.height = height;
7477        let surface = instance.create_surface(window.clone())?;
7478        surface.configure(&self.renderer.device, config);
7479        self.surface = Some(surface);
7480        self.pending_reconfigure = false;
7481        self.renderer.resize(width, height);
7482        Ok(())
7483    }
7484}
7485
7486#[cfg(feature = "winit-surface")]
7487impl RenderBackend for WgpuSurfaceBackend {
7488    fn configure_surface(&mut self, width: u32, height: u32) -> (u32, u32) {
7489        if width == 0 || height == 0 {
7490            self.renderer.resize(0, 0);
7491            self.pending_reconfigure = true;
7492            return (0, 0);
7493        }
7494        let (width, height) = clamp_output_size(
7495            width,
7496            height,
7497            self.renderer.device.limits().max_texture_dimension_2d,
7498        );
7499        if self.renderer.output_width == width && self.renderer.output_height == height {
7500            if let Some(ref mut config) = self.surface_config {
7501                config.width = width;
7502                config.height = height;
7503            }
7504            return (width, height);
7505        }
7506        if let Some(ref mut config) = self.surface_config {
7507            config.width = width;
7508            config.height = height;
7509        }
7510        self.renderer.resize(width, height);
7511        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref())
7512        {
7513            surface.configure(&self.renderer.device, config);
7514        }
7515        (width, height)
7516    }
7517
7518    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) -> bool {
7519        if self.pending_reconfigure {
7520            if let (Some(surface), Some(config)) =
7521                (self.surface.as_ref(), self.surface_config.as_ref())
7522            {
7523                surface.configure(&self.renderer.device, config);
7524            }
7525            self.pending_reconfigure = false;
7526        }
7527
7528        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
7529            request_frame();
7530            return false;
7531        }
7532        if let Err(error) = self.renderer.begin_frame() {
7533            log::warn!("begin renderer frame: {error:#}");
7534            request_frame();
7535            return false;
7536        }
7537
7538        let Some(surface) = self.surface.as_ref() else {
7539            self.renderer.end_frame();
7540            request_frame();
7541            return false;
7542        };
7543        let frame = match surface.get_current_texture() {
7544            wgpu::CurrentSurfaceTexture::Success(f) => f,
7545            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
7546                self.pending_reconfigure = true;
7547                f
7548            }
7549            other @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost) => {
7550                self.surface = None;
7551                let result = self
7552                    .window
7553                    .clone()
7554                    .ok_or_else(|| anyhow::anyhow!("no window retained for surface recreation"))
7555                    .and_then(|window| self.recreate_surface(&window));
7556                if let Err(error) = result {
7557                    log::warn!("surface {other:?}; recreation deferred: {error:#}");
7558                }
7559                self.renderer.end_frame();
7560                request_frame();
7561                return false;
7562            }
7563            other => {
7564                match other {
7565                    wgpu::CurrentSurfaceTexture::Validation => {
7566                        log::warn!("surface {other:?}; reconfiguring next frame");
7567                        self.pending_reconfigure = true;
7568                    }
7569                    wgpu::CurrentSurfaceTexture::Timeout
7570                    | wgpu::CurrentSurfaceTexture::Occluded => {
7571                        log::debug!("surface {other:?}; retrying next frame");
7572                    }
7573                    _ => {}
7574                }
7575                self.renderer.end_frame();
7576                request_frame();
7577                return false;
7578            }
7579        };
7580
7581        // The swapchain extent and the renderer's size are updated from
7582        // separate events, so they can disagree for a frame mid-resize (most
7583        // visibly when dragging a window into a portrait shape). With MSAA on,
7584        // a color attachment whose extent differs from its resolve target
7585        // fails wgpu validation, which drops the whole pass *including its
7586        // clear*, so the just-reconfigured swapchain keeps undefined contents
7587        // and presents as green/black streaks. Adopt the swapchain's size and
7588        // retry next frame instead of rendering an invalid pass.
7589        let (swap_w, swap_h) = (frame.texture.width(), frame.texture.height());
7590        if swap_w != self.renderer.output_width || swap_h != self.renderer.output_height {
7591            log::debug!(
7592                "swapchain {swap_w}x{swap_h} != renderer {}x{}; resyncing",
7593                self.renderer.output_width,
7594                self.renderer.output_height
7595            );
7596            drop(frame);
7597            if let Some(config) = self.surface_config.as_mut() {
7598                config.width = swap_w;
7599                config.height = swap_h;
7600            }
7601            self.renderer.resize(swap_w, swap_h);
7602            self.pending_reconfigure = true;
7603            self.renderer.end_frame();
7604            request_frame();
7605            return false;
7606        }
7607
7608        let swap_view = if let Some(view_format) = self
7609            .surface_config
7610            .as_ref()
7611            .and_then(|c| c.view_formats.iter().find(|f| f.is_srgb()).copied())
7612        {
7613            frame.texture.create_view(&wgpu::TextureViewDescriptor {
7614                format: Some(view_format),
7615                ..Default::default()
7616            })
7617        } else {
7618            frame
7619                .texture
7620                .create_view(&wgpu::TextureViewDescriptor::default())
7621        };
7622        let mut encoder =
7623            self.renderer
7624                .device
7625                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7626                    label: Some("frame encoder"),
7627                });
7628
7629        self.renderer.render_scene_to_encoder_with_texture(
7630            scene,
7631            &mut encoder,
7632            &swap_view,
7633            Some(&frame.texture),
7634            None,
7635        );
7636
7637        //NOTE: The WebGL HAL present path (fullscreen triangle / blit) does not
7638        // restore gl.colorMask. Hence this is needed to prevent frames from going transparent.
7639        {
7640            let _reset = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
7641                label: Some("webgl color_mask reset before present"),
7642                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7643                    view: &swap_view,
7644                    resolve_target: None,
7645                    ops: wgpu::Operations {
7646                        load: wgpu::LoadOp::Load,
7647                        store: wgpu::StoreOp::Store,
7648                    },
7649                    depth_slice: None,
7650                })],
7651                depth_stencil_attachment: None,
7652                timestamp_writes: None,
7653                occlusion_query_set: None,
7654                multiview_mask: None,
7655            });
7656        }
7657
7658        self.renderer
7659            .queue
7660            .submit(std::iter::once(encoder.finish()));
7661        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
7662            log::warn!("queue.present panicked: {:?}", e);
7663            self.renderer.end_frame();
7664            request_frame();
7665            return false;
7666        }
7667        self.renderer.end_frame();
7668        true
7669    }
7670}
7671
7672impl WgpuSceneRenderer {
7673    /// Open a translator-owned flatten layer for a perspective `PushTransform`.
7674    ///
7675    /// True perspective cannot ride the affine instance fast path, so the
7676    /// subtree renders flat into an offscreen layer and is composited back
7677    /// projectively on the matching pop (CSS-style flattening). The layer
7678    /// rect is the currently visible scissor in this target: content outside
7679    /// it is invisible in the parent, so clipping it in the layer changes
7680    /// nothing. Children keep the node's affine part on the stack (so
7681    /// `combine` stays affine-only) plus a layer-local shift, exactly like
7682    /// producer-owned blur layers - which is exact under rigid ancestors
7683    /// (translations commute) and the documented layer contract otherwise.
7684    #[allow(clippy::too_many_arguments)]
7685    fn replay_active_clips(
7686        &mut self,
7687        clips: &[ActiveClip],
7688        parent_transform: &Transform,
7689        origin: (f32, f32),
7690        target_size: (f32, f32),
7691        pass: &mut Pass,
7692        encoder: &mut wgpu::CommandEncoder,
7693    ) -> Vec<ActiveClip> {
7694        let mut current = repose_core::Rect {
7695            x: 0.0,
7696            y: 0.0,
7697            w: target_size.0,
7698            h: target_size.1,
7699        };
7700        let mut replayed = Vec::with_capacity(clips.len());
7701        for (index, clip) in clips.iter().enumerate() {
7702            let has_inverse = clips[..index].iter().any(ActiveClip::difference);
7703            let mut blocked = clip.blocked();
7704            if has_inverse && !blocked {
7705                log::error!(
7706                    "unsupported clip nesting: a Difference clip cannot contain another clip"
7707                );
7708                blocked = true;
7709            }
7710            let difference = clip.difference();
7711            let mut command = None;
7712            match clip {
7713                ActiveClip::Rect {
7714                    off: _,
7715                    cnt: _,
7716                    rect,
7717                    radii,
7718                    ..
7719                } => {
7720                    let local_rect = parent_rect_to_layer_rect(*rect, parent_transform, origin)
7721                        .unwrap_or(repose_core::Rect {
7722                            x: 0.0,
7723                            y: 0.0,
7724                            w: 0.0,
7725                            h: 0.0,
7726                        });
7727                    let local_radii =
7728                        parent_radii_to_layer_radii(*radii, parent_transform).unwrap_or([0.0; 4]);
7729                    let next = if difference {
7730                        current
7731                    } else {
7732                        intersect(current, local_rect)
7733                    };
7734                    let scissor = rect_to_scissor(
7735                        next,
7736                        target_size.0.max(1.0) as u32,
7737                        target_size.1.max(1.0) as u32,
7738                    );
7739                    let has_area = local_rect.x.is_finite()
7740                        && local_rect.y.is_finite()
7741                        && local_rect.w.is_finite()
7742                        && local_rect.h.is_finite()
7743                        && local_rect.w > 0.0
7744                        && local_rect.h > 0.0;
7745                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7746                        blocked = blocked || !difference;
7747                    } else {
7748                        let ndc = rect_to_ndc(local_rect, target_size.0, target_size.1);
7749                        let instance = ClipInstance {
7750                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
7751                            radii: local_radii,
7752                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
7753                        };
7754                        let bytes = bytemuck::bytes_of(&instance);
7755                        if self
7756                            .clip_ring
7757                            .grow_to_fit(
7758                                &self.device,
7759                                encoder,
7760                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
7761                            )
7762                            .is_ok()
7763                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
7764                        {
7765                            command = Some(Cmd::ClipPush {
7766                                off: new_off,
7767                                cnt: 1,
7768                                scissor,
7769                                difference,
7770                                applied: true,
7771                            });
7772                            replayed.push(ActiveClip::Rect {
7773                                off: new_off,
7774                                cnt: 1,
7775                                rect: local_rect,
7776                                radii: local_radii,
7777                                scissor,
7778                                difference,
7779                                applied: true,
7780                                blocked: false,
7781                            });
7782                        } else {
7783                            blocked = blocked || !difference;
7784                        }
7785                    }
7786                    current = next;
7787                }
7788                ActiveClip::Vector {
7789                    mesh,
7790                    source,
7791                    affine,
7792                    ..
7793                } => {
7794                    let local_affine =
7795                        parent_affine_to_layer_affine(*affine, parent_transform, origin)
7796                            .unwrap_or([0.0; 6]);
7797                    let aabb = mesh_aabb(source, local_affine);
7798                    let next = if difference {
7799                        current
7800                    } else {
7801                        intersect(current, aabb)
7802                    };
7803                    let scissor = rect_to_scissor(
7804                        next,
7805                        target_size.0.max(1.0) as u32,
7806                        target_size.1.max(1.0) as u32,
7807                    );
7808                    let has_area = aabb.x.is_finite()
7809                        && aabb.y.is_finite()
7810                        && aabb.w.is_finite()
7811                        && aabb.h.is_finite()
7812                        && aabb.w > 0.0
7813                        && aabb.h > 0.0
7814                        && mesh
7815                            .as_ref()
7816                            .is_some_and(|mesh| mesh.vertex_count > 0 && mesh.index_count > 0);
7817                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7818                        blocked = blocked || !difference;
7819                    } else if let Some(mesh_gpu) = mesh.clone()
7820                        && let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
7821                            local_affine,
7822                            &repose_core::PaintDesc::Solid,
7823                        ))
7824                    {
7825                        command = Some(Cmd::VectorClipPush {
7826                            mesh: mesh_gpu.clone(),
7827                            uoff,
7828                            scissor,
7829                            difference,
7830                            applied: true,
7831                        });
7832                        replayed.push(ActiveClip::Vector {
7833                            mesh: Some(mesh_gpu),
7834                            source: source.clone(),
7835                            uoff,
7836                            affine: local_affine,
7837                            scissor,
7838                            difference,
7839                            applied: true,
7840                            blocked: false,
7841                        });
7842                    } else {
7843                        blocked = blocked || !difference;
7844                    }
7845                    current = next;
7846                }
7847            }
7848            if blocked {
7849                current = repose_core::Rect {
7850                    x: 0.0,
7851                    y: 0.0,
7852                    w: 0.0,
7853                    h: 0.0,
7854                };
7855            }
7856            if command.is_none() {
7857                replayed.push(match clip {
7858                    ActiveClip::Rect {
7859                        rect,
7860                        radii,
7861                        scissor,
7862                        difference,
7863                        ..
7864                    } => ActiveClip::Rect {
7865                        off: 0,
7866                        cnt: 0,
7867                        rect: *rect,
7868                        radii: *radii,
7869                        scissor: *scissor,
7870                        difference: *difference,
7871                        applied: false,
7872                        blocked,
7873                    },
7874                    ActiveClip::Vector {
7875                        mesh,
7876                        source,
7877                        affine,
7878                        scissor,
7879                        difference,
7880                        ..
7881                    } => ActiveClip::Vector {
7882                        mesh: mesh.clone(),
7883                        source: source.clone(),
7884                        uoff: 0,
7885                        affine: parent_affine_to_layer_affine(*affine, parent_transform, origin)
7886                            .unwrap_or([0.0; 6]),
7887                        scissor: *scissor,
7888                        difference: *difference,
7889                        applied: false,
7890                        blocked,
7891                    },
7892                });
7893            }
7894            if let Some(command) = command {
7895                pass.cmds.push(command);
7896            }
7897            pass.active_scissor = if blocked {
7898                None
7899            } else {
7900                let scissor = rect_to_scissor(
7901                    current,
7902                    target_size.0.max(1.0) as u32,
7903                    target_size.1.max(1.0) as u32,
7904                );
7905                (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
7906            };
7907        }
7908        replayed
7909    }
7910
7911    #[allow(clippy::too_many_arguments)]
7912    fn push_perspective_layer(
7913        &mut self,
7914        node: Transform,
7915        top: Transform,
7916        transform_stack: &mut Vec<Transform>,
7917        scissor_stack: &mut Vec<repose_core::Rect>,
7918        root_clip_rect: &mut repose_core::Rect,
7919        current_target_size: &mut (f32, f32),
7920        current_pass: &mut Pass,
7921        active_clips: &mut Vec<ActiveClip>,
7922        passes: &mut Vec<Pass>,
7923        target_stack: &mut Vec<PassTarget>,
7924        flatten_stack: &mut Vec<FlattenRecord>,
7925        id_head: &mut u32,
7926        pass_id_head: &mut u64,
7927        ids_used: &mut Vec<u32>,
7928        encoder: &mut wgpu::CommandEncoder,
7929    ) {
7930        // Full projective map: affine ancestors over the node's map.
7931        // Ancestors are affine by construction (perspective always flattens
7932        // at push, and only stripped affines reach the stack).
7933        let map =
7934            Transform::compose_projective(&top.projective_matrix(), &node.projective_matrix());
7935        let scr = scissor_stack.last().copied().unwrap_or(*root_clip_rect);
7936        let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
7937        if !scr.w.is_finite()
7938            || !scr.h.is_finite()
7939            || scr.w < 1.0
7940            || scr.h < 1.0
7941            || scr.w > max_dimension
7942            || scr.h > max_dimension
7943        {
7944            return;
7945        }
7946        let w = scr.w.ceil();
7947        let h = scr.h.ceil();
7948        let layer_rect = repose_core::Rect {
7949            x: scr.x,
7950            y: scr.y,
7951            w,
7952            h,
7953        };
7954        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
7955            return;
7956        }
7957        let layer_id = *id_head;
7958        *id_head = id_head.wrapping_add(1);
7959        ids_used.push(layer_id);
7960
7961        let stack_len = transform_stack.len();
7962        // Children render with the ancestors' map only: the node's own
7963        // affine part lives in `map` and applies once, at composite time.
7964        // (Pushing the stripped affine here too would foreshorten twice.)
7965        transform_stack.push(top);
7966        transform_stack.push(Transform::translate(-layer_rect.x, -layer_rect.y));
7967
7968        let saved_scissor_stack = std::mem::replace(
7969            scissor_stack,
7970            vec![repose_core::Rect {
7971                x: 0.0,
7972                y: 0.0,
7973                w,
7974                h,
7975            }],
7976        );
7977        let saved_root = std::mem::replace(
7978            root_clip_rect,
7979            repose_core::Rect {
7980                x: 0.0,
7981                y: 0.0,
7982                w,
7983                h,
7984            },
7985        );
7986        let saved_size = std::mem::replace(current_target_size, (w, h));
7987        let prev_target = current_pass.target;
7988        let saved_active_scissor = current_pass.active_scissor;
7989        let saved_clips = std::mem::take(active_clips);
7990        let layer_pass_id = *pass_id_head;
7991        *pass_id_head += 1;
7992        let mut layer_pass = Pass {
7993            id: layer_pass_id,
7994            target: PassTarget::Layer(layer_id),
7995            initial_scissor: (0, 0, w as u32, h as u32),
7996            active_scissor: Some((0, 0, w as u32, h as u32)),
7997            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
7998            cmds: Vec::new(),
7999        };
8000        let layer_clips = self.replay_active_clips(
8001            &saved_clips,
8002            &top,
8003            (layer_rect.x, layer_rect.y),
8004            (w, h),
8005            &mut layer_pass,
8006            encoder,
8007        );
8008        *active_clips = layer_clips;
8009        let saved = std::mem::replace(current_pass, layer_pass);
8010        passes.push(saved);
8011        target_stack.push(prev_target);
8012        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8013        *current_target_size = (w, h);
8014        flatten_stack.push(FlattenRecord {
8015            stack_len,
8016            layer_id,
8017            map,
8018            layer_rect,
8019            saved_scissor_stack,
8020            saved_root,
8021            saved_size,
8022            saved_active_scissor,
8023            saved_clips,
8024        });
8025    }
8026
8027    /// Close a flatten layer: restore the parent target and composite the
8028    /// layer texture through the recorded projective map.
8029    #[allow(clippy::too_many_arguments)]
8030    fn pop_perspective_layer(
8031        &mut self,
8032        rec: FlattenRecord,
8033        scissor_stack: &mut Vec<repose_core::Rect>,
8034        root_clip_rect: &mut repose_core::Rect,
8035        current_target_size: &mut (f32, f32),
8036        current_pass: &mut Pass,
8037        active_clips: &mut Vec<ActiveClip>,
8038        passes: &mut Vec<Pass>,
8039        target_stack: &mut Vec<PassTarget>,
8040        pass_id_head: &mut u64,
8041        encoder: &mut wgpu::CommandEncoder,
8042    ) {
8043        *scissor_stack = rec.saved_scissor_stack;
8044        *root_clip_rect = rec.saved_root;
8045        *current_target_size = rec.saved_size;
8046        *active_clips = rec.saved_clips;
8047        let resumed_pass_id = *pass_id_head;
8048        *pass_id_head += 1;
8049        let saved = std::mem::replace(
8050            current_pass,
8051            Pass {
8052                id: resumed_pass_id,
8053                target: target_stack.pop().unwrap_or(PassTarget::Surface),
8054                initial_scissor: rec.saved_active_scissor.unwrap_or((0, 0, 1, 1)),
8055                active_scissor: rec.saved_active_scissor,
8056                clear_color: None,
8057                cmds: Vec::new(),
8058            },
8059        );
8060        passes.push(saved);
8061
8062        // Project the layer-rect corners (parent space) to NDC in the
8063        // resumed (parent) target, keeping each corner's homogeneous w for
8064        // perspective-correct sampling.
8065        let (tw, th) = rec.saved_size;
8066        let r = rec.layer_rect;
8067        let corners = [
8068            (r.x, r.y),
8069            (r.x + r.w, r.y),
8070            (r.x + r.w, r.y + r.h),
8071            (r.x, r.y + r.h),
8072        ];
8073        let mut ndc = [[0.0f32; 2]; 4];
8074        let mut ws = [1.0f32; 4];
8075        let mut all_behind = true;
8076        for (i, (x, y)) in corners.iter().enumerate() {
8077            let w_raw = rec.map[6] * x + rec.map[7] * y + rec.map[8];
8078            let w = if w_raw.abs() < 1e-6 {
8079                if w_raw < 0.0 { -1e-6 } else { 1e-6 }
8080            } else {
8081                w_raw
8082            };
8083            if w > 0.0 {
8084                all_behind = false;
8085            }
8086            let px = (rec.map[0] * x + rec.map[1] * y + rec.map[2]) / w;
8087            let py = (rec.map[3] * x + rec.map[4] * y + rec.map[5]) / w;
8088            ndc[i] = [px / tw * 2.0 - 1.0, 1.0 - py / th * 2.0];
8089            ws[i] = w;
8090        }
8091        if all_behind {
8092            // Entire subtree behind the viewer: nothing to composite (the
8093            // layer pass still ran, but its output is correctly discarded).
8094            return;
8095        }
8096        let Some(layer) = self.layer_pool.get(&rec.layer_id) else {
8097            return;
8098        };
8099        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
8100        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
8101        let inst = ProjectiveInstance {
8102            c0: ndc[0],
8103            c1: ndc[1],
8104            c2: ndc[2],
8105            c3: ndc[3],
8106            uv: [0.0, 0.0, uv_u1, uv_v1],
8107            w: ws,
8108            alpha: 1.0,
8109            _pad: [0.0; 3],
8110        };
8111        if self
8112            .projective_ring
8113            .grow_to_fit(
8114                &self.device,
8115                encoder,
8116                std::mem::size_of::<ProjectiveInstance>() as u64,
8117            )
8118            .is_err()
8119        {
8120            return;
8121        }
8122        let bytes = bytemuck::bytes_of(&inst);
8123        let Ok(off) = self.projective_ring.alloc_write(&self.queue, bytes) else {
8124            return;
8125        };
8126        current_pass.cmds.push(Cmd::CompositeProjective {
8127            off,
8128            cnt: 1,
8129            layer_id: rec.layer_id,
8130        });
8131    }
8132
8133    fn mesh_cache_key(mesh: &Arc<repose_core::VectorMeshData>) -> MeshCacheKey {
8134        MeshCacheKey {
8135            source: Arc::as_ptr(mesh) as usize,
8136            index: 0,
8137            kind: 1,
8138            vertex_len: mesh.vertices.len(),
8139            index_len: mesh.indices.len(),
8140        }
8141    }
8142
8143    fn overlay_mesh_cache_key(
8144        meshes: &Arc<[repose_core::VectorMeshData]>,
8145        index: usize,
8146    ) -> MeshCacheKey {
8147        let mesh = &meshes[index];
8148        MeshCacheKey {
8149            source: meshes.as_ptr() as usize,
8150            index,
8151            kind: 2,
8152            vertex_len: mesh.vertices.len(),
8153            index_len: mesh.indices.len(),
8154        }
8155    }
8156
8157    fn mesh_owner_matches(owner: &MeshOwner, other: &MeshOwner) -> bool {
8158        match (owner, other) {
8159            (MeshOwner::Object(a), MeshOwner::Object(b)) => Arc::ptr_eq(a, b),
8160            (MeshOwner::Overlay(a), MeshOwner::Overlay(b)) => Arc::ptr_eq(a, b),
8161            _ => false,
8162        }
8163    }
8164
8165    fn evict_mesh_cache(&mut self) -> bool {
8166        let victim = self
8167            .mesh_cache
8168            .iter()
8169            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
8170            .min_by_key(|(_, entry)| entry.last_touch_frame)
8171            .map(|(key, _)| *key);
8172        let Some(victim) = victim else {
8173            return false;
8174        };
8175        let Some(entry) = self.mesh_cache.remove(&victim) else {
8176            return false;
8177        };
8178        self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8179        true
8180    }
8181
8182    fn reserve_mesh_cache(&mut self, bytes: u64) -> bool {
8183        while self.mesh_cache.len() >= MAX_MESH_CACHE_ENTRIES
8184            || self.mesh_cache_bytes_total.saturating_add(bytes) > MAX_MESH_CACHE_BYTES
8185        {
8186            if !self.evict_mesh_cache() {
8187                return false;
8188            }
8189        }
8190        true
8191    }
8192
8193    fn reserve_mesh_gpu_budget(&mut self, bytes: u64) -> bool {
8194        while self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes {
8195            if !self.evict_mesh_cache() {
8196                return false;
8197            }
8198        }
8199        true
8200    }
8201
8202    fn upload_mesh_geometry_with_owner(
8203        &mut self,
8204        mesh: &repose_core::VectorMeshData,
8205        key: MeshCacheKey,
8206        owner: MeshOwner,
8207    ) -> Option<MeshGpu> {
8208        let touch = self.next_cache_touch();
8209        let cached = self
8210            .mesh_cache
8211            .get(&key)
8212            .filter(|entry| Self::mesh_owner_matches(&entry.owner, &owner))
8213            .map(|entry| entry.gpu.clone());
8214        if let Some(gpu) = cached {
8215            if let Some(entry) = self.mesh_cache.get_mut(&key) {
8216                entry.last_used_frame = self.frame_index;
8217                entry.last_touch_frame = touch;
8218            }
8219            return Some(gpu);
8220        }
8221        if let Some(entry) = self.mesh_cache.remove(&key) {
8222            self.mesh_cache_bytes_total =
8223                self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8224        }
8225        if mesh.vertices.is_empty() || mesh.indices.is_empty() {
8226            log::warn!("vector mesh has no geometry");
8227            return None;
8228        }
8229        if !mesh.indices.len().is_multiple_of(3) {
8230            log::warn!("vector mesh index count is not a triangle list");
8231            return None;
8232        }
8233        let vertex_count = u32::try_from(mesh.vertices.len()).ok()?;
8234        let index_count = u32::try_from(mesh.indices.len()).ok()?;
8235        if mesh.indices.iter().any(|&index| index >= vertex_count) {
8236            log::warn!("vector mesh index is outside its vertex buffer");
8237            return None;
8238        }
8239        if mesh.vertices.iter().any(|vertex| {
8240            vertex.pos.iter().any(|value| !value.is_finite())
8241                || vertex.color.iter().any(|value| !value.is_finite())
8242                || vertex.uv.iter().any(|value| !value.is_finite())
8243        }) {
8244            log::warn!("vector mesh contains non-finite vertex data");
8245            return None;
8246        }
8247        let verts: Vec<MeshVertex> = mesh
8248            .vertices
8249            .iter()
8250            .map(|v| MeshVertex {
8251                pos: v.pos,
8252                color: v.color,
8253                uv: v.uv,
8254            })
8255            .collect();
8256        let vbytes = bytemuck::cast_slice(&verts);
8257        let ibytes = bytemuck::cast_slice(&mesh.indices[..]);
8258        let bytes = u64::try_from(vbytes.len())
8259            .ok()?
8260            .checked_add(u64::try_from(ibytes.len()).ok()?)?;
8261        let max_buffer_size = self.device.limits().max_buffer_size;
8262        if vbytes.len() as u64 > max_buffer_size || ibytes.len() as u64 > max_buffer_size {
8263            return None;
8264        }
8265        let cacheable = bytes <= MAX_MESH_CACHE_BYTES
8266            && self.reserve_mesh_cache(bytes)
8267            && self.reserve_mesh_gpu_budget(bytes);
8268        let vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
8269            label: Some("vector mesh vertices"),
8270            size: vbytes.len().max(4) as u64,
8271            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
8272            mapped_at_creation: false,
8273        });
8274        let indices = self.device.create_buffer(&wgpu::BufferDescriptor {
8275            label: Some("vector mesh indices"),
8276            size: ibytes.len().max(4) as u64,
8277            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
8278            mapped_at_creation: false,
8279        });
8280        self.queue.write_buffer(&vertices, 0, vbytes);
8281        self.queue.write_buffer(&indices, 0, ibytes);
8282        let gpu = MeshGpu {
8283            vertices,
8284            indices,
8285            vertex_count,
8286            index_count,
8287            bytes,
8288        };
8289        if cacheable {
8290            let touch = self.next_cache_touch();
8291            self.mesh_cache.insert(
8292                key,
8293                MeshCacheEntry {
8294                    owner,
8295                    gpu: gpu.clone(),
8296                    last_used_frame: self.frame_index,
8297                    last_touch_frame: touch,
8298                },
8299            );
8300            self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_add(bytes);
8301        }
8302        Some(gpu)
8303    }
8304
8305    fn upload_mesh_geometry(&mut self, mesh: &Arc<repose_core::VectorMeshData>) -> Option<MeshGpu> {
8306        self.upload_mesh_geometry_with_owner(
8307            mesh,
8308            Self::mesh_cache_key(mesh),
8309            MeshOwner::Object(mesh.clone()),
8310        )
8311    }
8312
8313    fn upload_overlay_mesh(
8314        &mut self,
8315        meshes: &Arc<[repose_core::VectorMeshData]>,
8316        index: usize,
8317    ) -> Option<MeshGpu> {
8318        self.upload_mesh_geometry_with_owner(
8319            &meshes[index],
8320            Self::overlay_mesh_cache_key(meshes, index),
8321            MeshOwner::Overlay(meshes.clone()),
8322        )
8323    }
8324
8325    fn alloc_mesh_uniform(&mut self, u: MeshUniform) -> Option<u64> {
8326        let size = std::mem::size_of::<MeshUniform>() as u64;
8327        let next = self
8328            .mesh_uniform_head
8329            .checked_add(self.mesh_uniform_alignment)?;
8330        if next > self.mesh_uniform_cap || next > u64::from(u32::MAX) {
8331            log::warn!("mesh uniform buffer exhausted; remaining meshes skipped");
8332            return None;
8333        }
8334        let slot = self.mesh_uniform_head;
8335        if slot.checked_add(size)? > self.mesh_uniform_cap {
8336            return None;
8337        }
8338        self.queue
8339            .write_buffer(&self.mesh_uniform_buf, slot, bytemuck::bytes_of(&u));
8340        self.mesh_uniform_head = next;
8341        Some(slot)
8342    }
8343
8344    /// Shared isolation + backdrop composite for a source that needs the
8345    /// backdrop: snapshot the parent's region, render the source alone into a
8346    /// translator-owned graphics layer via `emit_source`, then composite that
8347    /// layer over the snapshot with the backdrop-blend shader. Works for
8348    /// surface parents and layer parents alike: the snapshot copy, isolation
8349    /// layer, and composite quad are all expressed in the parent target's
8350    /// pixel space. Callers must invoke this while the current pass targets
8351    /// the recorded parent: the translator never splits passes between here
8352    /// and the appended composite (only `BeginLayer`/perspective push new
8353    /// passes, and neither can intervene mid-call). The executor re-checks
8354    /// this (`BlendLayer.parent`) and skips a misplaced composite rather than
8355    /// drawing over the wrong target.
8356    ///
8357    /// `bounds` is the source's AABB in parent-target pixels; `emit_source`
8358    /// receives the isolation pass, the source-shifted transform, and the
8359    /// layer size in pixels.
8360    #[allow(clippy::too_many_arguments)]
8361    fn emit_isolated_blend_source(
8362        &mut self,
8363        bounds: repose_core::Rect,
8364        blend: repose_core::BlendMode,
8365        current_transform: &repose_core::Transform,
8366        current_pass: &mut Pass,
8367        active_clips: &[ActiveClip],
8368        passes: &mut Vec<Pass>,
8369        id_head: &mut u32,
8370        pass_id_head: &mut u64,
8371        ids_used: &mut Vec<u32>,
8372        scissor: (u32, u32, u32, u32),
8373        encoder: &mut wgpu::CommandEncoder,
8374        fb_w: f32,
8375        fb_h: f32,
8376        emit_source: impl FnOnce(
8377            &mut Self,
8378            &mut Pass,
8379            &repose_core::Transform,
8380            f32,
8381            f32,
8382            &mut wgpu::CommandEncoder,
8383        ),
8384    ) {
8385        if scissor.2 == 0 || scissor.3 == 0 || active_clips.iter().any(ActiveClip::blocked) {
8386            return;
8387        }
8388        let parent_target = current_pass.target;
8389        let (parent_w, parent_h) = match parent_target {
8390            PassTarget::Surface => (fb_w, fb_h),
8391            PassTarget::Layer(id) => match self.layer_pool.get(&id) {
8392                Some(layer) => (layer.width as f32, layer.height as f32),
8393                None => return,
8394            },
8395        };
8396        let local_rect = intersect(
8397            bounds,
8398            repose_core::Rect {
8399                x: 0.0,
8400                y: 0.0,
8401                w: parent_w,
8402                h: parent_h,
8403            },
8404        );
8405        if !local_rect.x.is_finite()
8406            || !local_rect.y.is_finite()
8407            || !local_rect.w.is_finite()
8408            || !local_rect.h.is_finite()
8409            || local_rect.w <= 0.0
8410            || local_rect.h <= 0.0
8411            || local_rect.w > self.device.limits().max_texture_dimension_2d as f32
8412            || local_rect.h > self.device.limits().max_texture_dimension_2d as f32
8413        {
8414            return;
8415        }
8416        let layer_rect = repose_core::Rect {
8417            x: local_rect.x.floor(),
8418            y: local_rect.y.floor(),
8419            w: (local_rect.x + local_rect.w).ceil() - local_rect.x.floor(),
8420            h: (local_rect.y + local_rect.h).ceil() - local_rect.y.floor(),
8421        };
8422        let w = layer_rect.w;
8423        let h = layer_rect.h;
8424        let local_rect = layer_rect;
8425        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8426            return;
8427        }
8428        let layer_id = *id_head;
8429        *id_head = id_head.wrapping_add(1);
8430        ids_used.push(layer_id);
8431
8432        // Snapshot the parent target region before compositing: the blend
8433        // shader samples it as the backdrop. The copy runs at execution
8434        // time, after all earlier passes have rendered.
8435        let snapshot_format = match parent_target {
8436            PassTarget::Surface if self.working_space => wgpu::TextureFormat::Rgba16Float,
8437            PassTarget::Surface => self.output_format,
8438            PassTarget::Layer(_) => self.layer_target_format(),
8439        };
8440        if !self.alloc_blend_snapshot(layer_id, w as u32, h as u32, snapshot_format) {
8441            log::warn!("blend snapshot budget exhausted; blend skipped");
8442            return;
8443        }
8444        let source_pass_id = *pass_id_head;
8445        *pass_id_head += 1;
8446        let resumed_pass_id = *pass_id_head;
8447        *pass_id_head += 1;
8448        self.blend_copies.push(BlendCopy {
8449            pass_id: resumed_pass_id,
8450            blend_id: layer_id,
8451            target: parent_target,
8452            region: layer_rect,
8453        });
8454
8455        // Render the mesh alone into the layer (layer-local shift so the
8456        // layer owns exactly the mesh bbox), then resume the parent pass.
8457        // The composite runs in the resumed parent pass.
8458        let mut layer_pass = Pass {
8459            id: source_pass_id,
8460            target: PassTarget::Layer(layer_id),
8461            initial_scissor: (0, 0, w as u32, h as u32),
8462            active_scissor: Some((0, 0, w as u32, h as u32)),
8463            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8464            cmds: Vec::new(),
8465        };
8466        self.replay_active_clips(
8467            active_clips,
8468            current_transform,
8469            (local_rect.x, local_rect.y),
8470            (w, h),
8471            &mut layer_pass,
8472            encoder,
8473        );
8474        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8475        let shift = repose_core::Transform::translate(-local_rect.x, -local_rect.y);
8476        let local = current_transform.combine(&shift);
8477        emit_source(self, &mut layer_pass, &local, w, h, encoder);
8478        let saved = std::mem::replace(
8479            current_pass,
8480            Pass {
8481                id: resumed_pass_id,
8482                target: parent_target,
8483                initial_scissor: scissor,
8484                active_scissor: Some(scissor),
8485                clear_color: None,
8486                cmds: Vec::new(),
8487            },
8488        );
8489        passes.push(layer_pass);
8490        passes.push(saved);
8491
8492        // Composite quad over the mesh bbox in the parent target's pixel
8493        // space. The executor maps this NDC against `parent`, so layer
8494        // parents at any origin work.
8495        let ndc = {
8496            let cx = local_rect.x + local_rect.w * 0.5;
8497            let cy = local_rect.y + local_rect.h * 0.5;
8498            let ndc_cx = (cx / parent_w) * 2.0 - 1.0;
8499            let ndc_cy = 1.0 - (cy / parent_h) * 2.0;
8500            let ndc_w = (local_rect.w / parent_w) * 2.0;
8501            let ndc_h = (local_rect.h / parent_h) * 2.0;
8502            [ndc_cx, ndc_cy, ndc_w, ndc_h]
8503        };
8504        let inst = BlendInstance {
8505            xywh: ndc,
8506            uv: [0.0, 0.0, 1.0, 1.0],
8507            color: [1.0, 1.0, 1.0, 1.0],
8508            fwd_mat: [1.0, 0.0, 0.0, 1.0],
8509            mode: blend.shader_mode(),
8510            _pad: [0.0; 3],
8511        };
8512        if self
8513            .blend_ring
8514            .grow_to_fit(
8515                &self.device,
8516                encoder,
8517                std::mem::size_of::<BlendInstance>() as u64,
8518            )
8519            .is_err()
8520        {
8521            return;
8522        }
8523        let bytes = bytemuck::bytes_of(&inst);
8524        let Ok(off) = self.blend_ring.alloc_write(&self.queue, bytes) else {
8525            return;
8526        };
8527        current_pass.cmds.push(Cmd::BlendLayer {
8528            off,
8529            cnt: 1,
8530            src_layer: layer_id,
8531            dst_layer: Some(layer_id),
8532            parent: parent_target,
8533            scissor,
8534        });
8535    }
8536
8537    /// [`Self::emit_isolated_blend_source`] for a `VectorMesh`: the isolation
8538    /// layer owns exactly the mesh bbox, shifted so the mesh lands at its
8539    /// world position inside the layer.
8540    #[allow(clippy::too_many_arguments)]
8541    fn emit_isolated_blend(
8542        &mut self,
8543        mesh: std::sync::Arc<repose_core::VectorMeshData>,
8544        transform: [f32; 6],
8545        paint: repose_core::PaintDesc,
8546        blend: repose_core::BlendMode,
8547        current_transform: &repose_core::Transform,
8548        current_pass: &mut Pass,
8549        active_clips: &[ActiveClip],
8550        passes: &mut Vec<Pass>,
8551        id_head: &mut u32,
8552        pass_id_head: &mut u64,
8553        ids_used: &mut Vec<u32>,
8554        scissor: (u32, u32, u32, u32),
8555        encoder: &mut wgpu::CommandEncoder,
8556        fb_w: f32,
8557        fb_h: f32,
8558    ) {
8559        let affine = combine_mesh_affine(current_transform, transform);
8560        let bounds = mesh_aabb(&mesh, affine);
8561        self.emit_isolated_blend_source(
8562            bounds,
8563            blend,
8564            current_transform,
8565            current_pass,
8566            active_clips,
8567            passes,
8568            id_head,
8569            pass_id_head,
8570            ids_used,
8571            scissor,
8572            encoder,
8573            fb_w,
8574            fb_h,
8575            |slf, pass, local, _w, _h, _encoder| {
8576                slf.emit_vector_mesh(
8577                    local,
8578                    &mesh,
8579                    transform,
8580                    &paint,
8581                    repose_core::BlendMode::Alpha,
8582                    &mut pass.cmds,
8583                );
8584            },
8585        );
8586    }
8587
8588    /// [`Self::emit_isolated_blend_source`] for an `ImageBlended` quad. The
8589    /// isolation layer is sized to the fitted draw rect and the image is
8590    /// sampled with its own UVs, so `fit`/`filter`/`source_rect` survive the
8591    /// round trip; the source layer composites with `Alpha` and the blend
8592    /// itself happens in the backdrop pass.
8593    #[allow(clippy::too_many_arguments)]
8594    fn emit_isolated_image(
8595        &mut self,
8596        draw_rect: repose_core::Rect,
8597        handle: u64,
8598        uv_rect: [f32; 4],
8599        tint: repose_core::Color,
8600        tile: bool,
8601        filter: ImageFilter,
8602        blend: repose_core::BlendMode,
8603        current_transform: &repose_core::Transform,
8604        current_pass: &mut Pass,
8605        active_clips: &[ActiveClip],
8606        passes: &mut Vec<Pass>,
8607        id_head: &mut u32,
8608        pass_id_head: &mut u64,
8609        ids_used: &mut Vec<u32>,
8610        scissor: (u32, u32, u32, u32),
8611        encoder: &mut wgpu::CommandEncoder,
8612        fb_w: f32,
8613        fb_h: f32,
8614    ) {
8615        let bounds = affine_aabb(current_transform, &draw_rect);
8616        self.emit_isolated_blend_source(
8617            bounds,
8618            blend,
8619            current_transform,
8620            current_pass,
8621            active_clips,
8622            passes,
8623            id_head,
8624            pass_id_head,
8625            ids_used,
8626            scissor,
8627            encoder,
8628            fb_w,
8629            fb_h,
8630            |slf, pass, local, w, h, encoder| {
8631                let (ndc_center, fwd_mat) = rect_to_instance_ndc(draw_rect, local, w, h);
8632                let inst = GlyphInstance {
8633                    xywh: ndc_center,
8634                    uv: uv_rect,
8635                    color: tint.to_linear(),
8636                    fwd_mat,
8637                };
8638                if let Some((off, _)) =
8639                    slf.glyph_color
8640                        .upload(&slf.device, &slf.queue, encoder, &[inst])
8641                {
8642                    pass.cmds.push(Cmd::ImageRgba {
8643                        off,
8644                        cnt: 1,
8645                        handle,
8646                        filter,
8647                        tile,
8648                        blend: repose_core::BlendMode::Alpha,
8649                    });
8650                }
8651            },
8652        );
8653    }
8654
8655    fn recycle_blend_snapshot(&mut self, snapshot: BlendSnapshot) {
8656        let key = snapshot.key;
8657        if let Some(old) = self.blend_snapshot_pool.insert(key, snapshot) {
8658            self.blend_snapshot_bytes_total =
8659                self.blend_snapshot_bytes_total.saturating_sub(old.bytes);
8660        }
8661        while self.blend_snapshot_bytes_total > MAX_BLEND_SNAPSHOT_BYTES
8662            || self.blend_snapshot_pool.len() > MAX_BLEND_SNAPSHOT_POOL_ENTRIES
8663        {
8664            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8665                break;
8666            };
8667            if let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) {
8668                self.blend_snapshot_bytes_total = self
8669                    .blend_snapshot_bytes_total
8670                    .saturating_sub(snapshot.bytes);
8671            }
8672        }
8673    }
8674
8675    fn recycle_blend_snapshots(&mut self) {
8676        for (_, snapshot) in std::mem::take(&mut self.blend_snapshots) {
8677            self.recycle_blend_snapshot(snapshot);
8678        }
8679    }
8680
8681    fn alloc_blend_snapshot(
8682        &mut self,
8683        layer_id: u32,
8684        w: u32,
8685        h: u32,
8686        format: wgpu::TextureFormat,
8687    ) -> bool {
8688        let key = BlendSnapshotKey {
8689            format,
8690            width: w,
8691            height: h,
8692        };
8693        if self
8694            .blend_snapshots
8695            .get(&layer_id)
8696            .is_some_and(|snapshot| snapshot.key == key)
8697        {
8698            return true;
8699        }
8700        if w == 0 || h == 0 {
8701            return false;
8702        }
8703        if let Some(old) = self.blend_snapshots.remove(&layer_id) {
8704            self.recycle_blend_snapshot(old);
8705        }
8706        if let Some(snapshot) = self.blend_snapshot_pool.remove(&key) {
8707            self.blend_snapshots.insert(layer_id, snapshot);
8708            return true;
8709        }
8710        let Ok(bytes) = texture_storage_bytes(format, w, h) else {
8711            return false;
8712        };
8713        if bytes > MAX_BLEND_SNAPSHOT_BYTES {
8714            return false;
8715        }
8716        while self.blend_snapshot_bytes_total.saturating_add(bytes) > MAX_BLEND_SNAPSHOT_BYTES
8717            || self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes
8718        {
8719            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8720                return false;
8721            };
8722            let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) else {
8723                return false;
8724            };
8725            self.blend_snapshot_bytes_total = self
8726                .blend_snapshot_bytes_total
8727                .saturating_sub(snapshot.bytes);
8728        }
8729        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
8730            label: Some("blend backdrop snapshot"),
8731            size: wgpu::Extent3d {
8732                width: w,
8733                height: h,
8734                depth_or_array_layers: 1,
8735            },
8736            mip_level_count: 1,
8737            sample_count: 1,
8738            dimension: wgpu::TextureDimension::D2,
8739            format,
8740            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
8741            view_formats: &[],
8742        });
8743        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
8744        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8745            label: Some("blend snapshot bind"),
8746            layout: &self.image_bind_layout_rgba,
8747            entries: &[
8748                wgpu::BindGroupEntry {
8749                    binding: 0,
8750                    resource: wgpu::BindingResource::TextureView(&view),
8751                },
8752                wgpu::BindGroupEntry {
8753                    binding: 1,
8754                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
8755                },
8756            ],
8757        });
8758        self.blend_snapshot_bytes_total = self.blend_snapshot_bytes_total.saturating_add(bytes);
8759        self.blend_snapshots.insert(
8760            layer_id,
8761            BlendSnapshot {
8762                key,
8763                texture: tex,
8764                bind,
8765                bytes,
8766            },
8767        );
8768        true
8769    }
8770
8771    fn remove_blend_snapshot(&mut self, layer_id: u32) {
8772        if let Some(snapshot) = self.blend_snapshots.remove(&layer_id) {
8773            self.recycle_blend_snapshot(snapshot);
8774        }
8775    }
8776
8777    fn blend_snapshot_texture(&self, layer_id: u32) -> Option<wgpu::Texture> {
8778        self.blend_snapshots
8779            .get(&layer_id)
8780            .map(|s| s.texture.clone())
8781    }
8782
8783    #[allow(clippy::too_many_arguments)]
8784    fn emit_vector_mesh(
8785        &mut self,
8786        current_transform: &Transform,
8787        mesh: &Arc<repose_core::VectorMeshData>,
8788        transform: [f32; 6],
8789        paint: &repose_core::PaintDesc,
8790        blend: repose_core::BlendMode,
8791        cmds: &mut Vec<Cmd>,
8792    ) {
8793        let affine = combine_mesh_affine(current_transform, transform);
8794        let Some(mesh_gpu) = self.upload_mesh_geometry(mesh) else {
8795            return;
8796        };
8797        let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(affine, paint)) else {
8798            return;
8799        };
8800        cmds.push(Cmd::VectorMesh {
8801            mesh: mesh_gpu,
8802            uoff,
8803            blend,
8804        });
8805    }
8806
8807    fn touch_callback_scope(&mut self, scope: CallbackScopeKey) {
8808        self.callback_scope_clock = self.callback_scope_clock.wrapping_add(1);
8809        let tick = self.callback_scope_clock;
8810        self.callback_scope_uses.insert(
8811            scope,
8812            CallbackScopeUse {
8813                tick,
8814                frame: self.frame_index,
8815            },
8816        );
8817    }
8818
8819    fn remove_callback_scope(&mut self, scope: &CallbackScopeKey) {
8820        self.callback_scoped_resources.remove(scope);
8821        self.callback_scope_uses.remove(scope);
8822        self.callback_scope_payloads.remove(scope);
8823    }
8824
8825    fn evict_callback_scope(&mut self, protected: &HashSet<CallbackScopeKey>) -> bool {
8826        let candidate = self
8827            .callback_scoped_resources
8828            .keys()
8829            .filter(|scope| !protected.contains(*scope))
8830            .min_by_key(|scope| {
8831                self.callback_scope_uses
8832                    .get(scope)
8833                    .map(|usage| usage.tick)
8834                    .unwrap_or(0)
8835            })
8836            .cloned();
8837        let Some(scope) = candidate else {
8838            return false;
8839        };
8840        self.remove_callback_scope(&scope);
8841        true
8842    }
8843
8844    fn prune_callback_scopes(&mut self) {
8845        let current_frame = self.frame_index;
8846        let mut dead: Vec<(CallbackScopeKey, u64)> = self
8847            .callback_scope_uses
8848            .iter()
8849            .filter_map(|(scope, usage)| {
8850                let payload_dead = self
8851                    .callback_scope_payloads
8852                    .get(scope)
8853                    .is_none_or(|weak| weak.upgrade().is_none());
8854                (payload_dead && !scope.callback.reusable).then(|| (scope.clone(), usage.tick))
8855            })
8856            .collect();
8857        dead.sort_by_key(|(_, tick)| *tick);
8858        for (scope, _) in dead {
8859            self.remove_callback_scope(&scope);
8860        }
8861
8862        if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8863            return;
8864        }
8865        let mut inactive: Vec<(CallbackScopeKey, u64)> = self
8866            .callback_scope_uses
8867            .iter()
8868            .filter(|(_, usage)| usage.frame != current_frame)
8869            .map(|(scope, usage)| (scope.clone(), usage.tick))
8870            .collect();
8871        inactive.sort_by_key(|(_, tick)| *tick);
8872        for (scope, _) in inactive {
8873            if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8874                break;
8875            }
8876            self.remove_callback_scope(&scope);
8877        }
8878    }
8879
8880    pub fn begin_frame(&mut self) -> anyhow::Result<()> {
8881        if self.frame_active {
8882            self.last_render_error = Some("renderer frame is already active".into());
8883            anyhow::bail!("renderer frame is already active");
8884        }
8885        self.last_render_error = None;
8886        self.frame_active = true;
8887        self.frame_index = self.frame_index.wrapping_add(1);
8888        self.apply_atlas_pending();
8889        self.slug_cache.next_frame();
8890        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8891            composite.begin_frame();
8892        }
8893        for resources in self.callback_scoped_resources.values_mut() {
8894            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8895                composite.begin_frame();
8896            }
8897        }
8898        Ok(())
8899    }
8900
8901    pub fn end_frame(&mut self) {
8902        if !self.frame_active {
8903            return;
8904        }
8905        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8906            composite.end_frame();
8907        }
8908        for resources in self.callback_scoped_resources.values_mut() {
8909            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8910                composite.end_frame();
8911            }
8912        }
8913        self.prune_callback_scopes();
8914        self.evict_unused_images();
8915        self.frame_active = false;
8916    }
8917
8918    pub fn last_render_error(&self) -> Option<&str> {
8919        self.last_render_error.as_deref()
8920    }
8921
8922    pub fn render_scene_to_encoder(
8923        &mut self,
8924        scene: &Scene,
8925        encoder: &mut wgpu::CommandEncoder,
8926        target_view: &wgpu::TextureView,
8927        clear_color_override: Option<[f64; 4]>,
8928    ) {
8929        self.render_scene_to_encoder_with_texture(
8930            scene,
8931            encoder,
8932            target_view,
8933            None,
8934            clear_color_override,
8935        )
8936    }
8937
8938    /// Same as [`render_scene_to_encoder`](Self::render_scene_to_encoder),
8939    /// plus the render target texture for isolated-blend backdrop snapshots.
8940    /// Pass `None` when the texture is unavailable (swapchain views); then
8941    /// surface-targeted isolated blends are skipped (the isolated source
8942    /// layer composites nowhere, so the mesh disappears).
8943    pub fn render_scene_to_encoder_with_texture(
8944        &mut self,
8945        scene: &Scene,
8946        encoder: &mut wgpu::CommandEncoder,
8947        target_view: &wgpu::TextureView,
8948        target_texture: Option<&wgpu::Texture>,
8949        clear_color_override: Option<[f64; 4]>,
8950    ) {
8951        if self.output_width == 0 || self.output_height == 0 {
8952            return;
8953        }
8954        self.sync_text_caches();
8955        fn visible(aabb: repose_core::Rect, clip: repose_core::Rect) -> bool {
8956            if !aabb.x.is_finite()
8957                || !aabb.y.is_finite()
8958                || !aabb.w.is_finite()
8959                || !aabb.h.is_finite()
8960                || aabb.w <= 0.0
8961                || aabb.h <= 0.0
8962            {
8963                return false;
8964            }
8965            let aabb = repose_core::Rect {
8966                x: aabb.x - 1.0,
8967                y: aabb.y - 1.0,
8968                w: aabb.w + 2.0,
8969                h: aabb.h + 2.0,
8970            };
8971            let clipped = intersect(aabb, clip);
8972            clipped.x.is_finite()
8973                && clipped.y.is_finite()
8974                && clipped.w.is_finite()
8975                && clipped.h.is_finite()
8976                && clipped.w > 0.0
8977                && clipped.h > 0.0
8978        }
8979
8980        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
8981            let x0 = (x / fb_w) * 2.0 - 1.0;
8982            let y0 = 1.0 - (y / fb_h) * 2.0;
8983            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
8984            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
8985            let min_x = x0.min(x1);
8986            let min_y = y0.min(y1);
8987            let w_ndc = (x1 - x0).abs();
8988            let h_ndc = (y1 - y0).abs();
8989            [min_x, min_y, w_ndc, h_ndc]
8990        }
8991
8992        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
8993            if fb_w == 0
8994                || fb_h == 0
8995                || !r.x.is_finite()
8996                || !r.y.is_finite()
8997                || !r.w.is_finite()
8998                || !r.h.is_finite()
8999                || r.w <= 0.0
9000                || r.h <= 0.0
9001            {
9002                return (0, 0, 0, 0);
9003            }
9004            // Inward: a clip must not admit any pixel outside the rect it
9005            // came from. Rounding out let clipped content bleed a pixel past
9006            // every clip edge, which shows up as a 1px line hugging the edge
9007            // of a letterboxed or clipped view.
9008            let x0 = r.x.ceil().max(0.0).min(fb_w as f32);
9009            let y0 = r.y.ceil().max(0.0).min(fb_h as f32);
9010            let x1 = (r.x + r.w).floor().max(x0).min(fb_w as f32);
9011            let y1 = (r.y + r.h).floor().max(y0).min(fb_h as f32);
9012            if x1 <= x0 || y1 <= y0 {
9013                return (0, 0, 0, 0);
9014            }
9015            (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
9016        }
9017
9018        let fb_w = self.output_width as f32;
9019        let fb_h = self.output_height as f32;
9020
9021        let mut passes: Vec<Pass> = Vec::with_capacity(1);
9022        let clear_color = clear_color_override
9023            .map(|color| {
9024                let alpha = color[3];
9025                [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha]
9026            })
9027            .unwrap_or_else(|| {
9028                let color = scene.clear_color.to_linear();
9029                let alpha = color[3] as f64;
9030                [
9031                    color[0] as f64 * alpha,
9032                    color[1] as f64 * alpha,
9033                    color[2] as f64 * alpha,
9034                    alpha,
9035                ]
9036            });
9037        let mut next_pass_id = 0u64;
9038        let mut current_pass: Pass = Pass {
9039            id: next_pass_id,
9040            target: PassTarget::Surface,
9041            initial_scissor: (0, 0, self.output_width, self.output_height),
9042            active_scissor: Some((0, 0, self.output_width, self.output_height)),
9043            clear_color: Some([
9044                clear_color[0] as f32,
9045                clear_color[1] as f32,
9046                clear_color[2] as f32,
9047                clear_color[3] as f32,
9048            ]),
9049            cmds: Vec::with_capacity(scene.nodes.len()),
9050        };
9051        next_pass_id += 1;
9052        let mut target_stack: Vec<PassTarget> = Vec::new();
9053        let mut layer_stack: Vec<LayerState> = Vec::new();
9054        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
9055
9056        struct Batch {
9057            rects: Vec<RectInstance>,
9058            borders: Vec<BorderInstance>,
9059            ellipses: Vec<EllipseInstance>,
9060            e_borders: Vec<EllipseBorderInstance>,
9061            arcs: Vec<ArcInstance>,
9062            masks: Vec<GlyphInstance>,
9063            colors: Vec<GlyphInstance>,
9064        }
9065
9066        impl Batch {
9067            fn new() -> Self {
9068                Self {
9069                    rects: vec![],
9070                    borders: vec![],
9071                    ellipses: vec![],
9072                    e_borders: vec![],
9073                    arcs: vec![],
9074                    masks: vec![],
9075                    colors: vec![],
9076                }
9077            }
9078
9079            fn is_empty(&self) -> bool {
9080                self.rects.is_empty()
9081                    && self.borders.is_empty()
9082                    && self.ellipses.is_empty()
9083                    && self.e_borders.is_empty()
9084                    && self.arcs.is_empty()
9085                    && self.masks.is_empty()
9086                    && self.colors.is_empty()
9087            }
9088
9089            fn flush(
9090                &mut self,
9091                pipes: (
9092                    &mut InstancedPipe<RectInstance>,
9093                    &mut InstancedPipe<BorderInstance>,
9094                    &mut InstancedPipe<EllipseInstance>,
9095                    &mut InstancedPipe<EllipseBorderInstance>,
9096                    &mut InstancedPipe<ArcInstance>,
9097                ),
9098                glyph_pipes: (
9099                    &mut InstancedPipe<GlyphInstance>,
9100                    &mut InstancedPipe<GlyphInstance>,
9101                ),
9102                device: &wgpu::Device,
9103                queue: &wgpu::Queue,
9104                encoder: &mut wgpu::CommandEncoder,
9105                cmds: &mut Vec<Cmd>,
9106            ) {
9107                let (rects, borders, ellipses, e_borders, arcs) = pipes;
9108                let (masks, colors) = glyph_pipes;
9109
9110                macro_rules! flush_one {
9111                    ($buf:ident, $pipe:expr, $variant:ident) => {
9112                        if !self.$buf.is_empty() {
9113                            if let Some((off, cnt)) =
9114                                $pipe.upload(device, queue, encoder, &self.$buf)
9115                            {
9116                                cmds.push(Cmd::$variant { off, cnt });
9117                            }
9118                            self.$buf.clear();
9119                        }
9120                    };
9121                }
9122
9123                flush_one!(rects, rects, Rect);
9124                flush_one!(borders, borders, Border);
9125                flush_one!(ellipses, ellipses, Ellipse);
9126                flush_one!(e_borders, e_borders, EllipseBorder);
9127                flush_one!(arcs, arcs, Arc);
9128                flush_one!(masks, masks, GlyphsMask);
9129                flush_one!(colors, colors, GlyphsColor);
9130            }
9131        }
9132
9133        self.rects.reset();
9134        self.borders.reset();
9135        self.ellipses.reset();
9136        self.ellipse_borders.reset();
9137        self.arcs.reset();
9138        self.glyph_mask.reset();
9139        self.glyph_color.reset();
9140        self.clip_ring.reset();
9141        self.blur_ring.reset();
9142        self.nv12.reset();
9143
9144        self.slug_ring.reset();
9145        self.mesh_uniform_head = 0;
9146        self.projective_ring.reset();
9147        self.blend_ring.reset();
9148        for id in self.flatten_layer_ids.drain(..) {
9149            if let Some(layer) = self.layer_pool.remove(&id) {
9150                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
9151                if layer.transient {
9152                    self.transient_layer_bytes_total =
9153                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
9154                }
9155            }
9156        }
9157        self.producer_layer_ids.clear();
9158        self.recycle_blend_snapshots();
9159        self.blend_copies.clear();
9160        let mut batch = Batch::new();
9161        // The blend mode is part of the run key: instances only batch together when
9162        // they resolve to the same pipeline.
9163        let mut image_run: Option<(
9164            u64,
9165            ImageFilter,
9166            bool,
9167            repose_core::BlendMode,
9168            Vec<GlyphInstance>,
9169        )> = None;
9170        let mut nv12_run: Option<(u64, ImageFilter, repose_core::BlendMode, Vec<Nv12Instance>)> =
9171            None;
9172        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
9173        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
9174        let mut flatten_stack: Vec<FlattenRecord> = Vec::new();
9175        let mut flatten_id_head: u32 = FLATTEN_ID_BASE;
9176        let mut flatten_ids_used: Vec<u32> = Vec::new();
9177        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
9178        let mut active_clips: Vec<ActiveClip> = Vec::with_capacity(8);
9179        let mut root_clip_rect = repose_core::Rect {
9180            x: 0.0,
9181            y: 0.0,
9182            w: fb_w,
9183            h: fb_h,
9184        };
9185
9186        let mut current_prim: Option<&'static str> = None;
9187
9188        macro_rules! flush_if_prim_changed {
9189            ($prim:literal, $pipe:expr) => {
9190                if current_prim != Some($prim) {
9191                    flush_batch!();
9192                    current_prim = Some($prim);
9193                }
9194            };
9195        }
9196
9197        macro_rules! flush_image_runs {
9198            () => {{
9199                if let Some((handle, filter, tile, blend, instances)) = image_run.take()
9200                    && let Some((off, cnt)) =
9201                        self.glyph_color
9202                            .upload(&self.device, &self.queue, encoder, &instances)
9203                {
9204                    current_pass.cmds.push(Cmd::ImageRgba {
9205                        off,
9206                        cnt,
9207                        handle,
9208                        filter,
9209                        tile,
9210                        blend,
9211                    });
9212                }
9213                if let Some((handle, filter, blend, instances)) = nv12_run.take()
9214                    && let Some((off, cnt)) =
9215                        self.nv12
9216                            .upload(&self.device, &self.queue, encoder, &instances)
9217                {
9218                    current_pass.cmds.push(Cmd::ImageNv12 {
9219                        off,
9220                        cnt,
9221                        handle,
9222                        filter,
9223                        blend,
9224                    });
9225                }
9226            }};
9227        }
9228
9229        macro_rules! flush_primitives {
9230            () => {{
9231                if !batch.is_empty() {
9232                    batch.flush(
9233                        (
9234                            &mut self.rects,
9235                            &mut self.borders,
9236                            &mut self.ellipses,
9237                            &mut self.ellipse_borders,
9238                            &mut self.arcs,
9239                        ),
9240                        (&mut self.glyph_mask, &mut self.glyph_color),
9241                        &self.device,
9242                        &self.queue,
9243                        encoder,
9244                        &mut current_pass.cmds,
9245                    )
9246                }
9247            }};
9248        }
9249
9250        macro_rules! flush_batch {
9251            () => {{
9252                flush_image_runs!();
9253                flush_primitives!();
9254            }};
9255        }
9256        let mut pending_layer_composite: Option<Cmd> = None;
9257        let shadow_layer_ids: HashSet<u32> = scene
9258            .nodes
9259            .iter()
9260            .filter_map(|node| match node {
9261                SceneNode::CompositeShadow { layer_id, .. } => Some(*layer_id),
9262                _ => None,
9263            })
9264            .collect();
9265        macro_rules! flush_pending_layer {
9266            () => {
9267                if let Some(command) = pending_layer_composite.take() {
9268                    current_pass.cmds.push(command);
9269                }
9270            };
9271        }
9272        for node in &scene.nodes {
9273            let t_identity = Transform::identity();
9274            let current_transform = transform_stack.last().unwrap_or(&t_identity);
9275
9276            // `Image` and `ImageBlended` differ only in the blend mode, and
9277            // or-patterns cannot bind a field one variant lacks, so lift both
9278            // out of the match and share the whole image path below.
9279            let image_spec = match node {
9280                SceneNode::Image {
9281                    rect,
9282                    handle,
9283                    tint,
9284                    style,
9285                } => Some((*rect, *handle, *tint, *style, repose_core::BlendMode::Alpha)),
9286                SceneNode::ImageBlended {
9287                    rect,
9288                    handle,
9289                    tint,
9290                    style,
9291                    blend,
9292                } => Some((*rect, *handle, *tint, *style, *blend)),
9293                _ => None,
9294            };
9295            if let Some((rect, handle, tint, style, blend)) = image_spec {
9296                let ImagePaintStyle {
9297                    fit,
9298                    filter,
9299                    source_rect,
9300                    alignment,
9301                } = style;
9302                let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9303                if !visible(affine_aabb(current_transform, &rect), clip) {
9304                    continue;
9305                }
9306                flush_batch!();
9307                let (img_w, img_h, is_nv12) = match self.resolve_image_for_draw(handle) {
9308                    Some(wh) => wh,
9309                    None => {
9310                        log::warn!("Image handle {} not found", handle);
9311                        continue;
9312                    }
9313                };
9314                let Some(source) = resolve_image_source(source_rect, img_w, img_h) else {
9315                    log::warn!(
9316                        "Image handle {} has invalid source rect {:?}",
9317                        handle,
9318                        source_rect
9319                    );
9320                    continue;
9321                };
9322                let Some((draw_rect, uv_rect)) = image_fit_geometry(rect, fit, alignment, &source)
9323                else {
9324                    continue;
9325                };
9326                let tile = matches!(fit, ImageFit::Tile);
9327                // Mirror the mesh path: backdrop-dependent modes isolate,
9328                // and so do the fixed-function ones when the source is
9329                // translucent (their blend states assume opaque source).
9330                let translucent_fixed_blend = matches!(
9331                    blend,
9332                    repose_core::BlendMode::Add
9333                        | repose_core::BlendMode::Multiply
9334                        | repose_core::BlendMode::Screen
9335                        | repose_core::BlendMode::Darken
9336                        | repose_core::BlendMode::Lighten
9337                ) && tint.3 < 255;
9338                if blend.needs_isolation() || translucent_fixed_blend {
9339                    if is_nv12 {
9340                        // NV12 sampling is only wired into the image
9341                        // pipeline; isolating would need an RGBA layer.
9342                        log::warn!("blend on an NV12 image is not supported");
9343                    } else {
9344                        let blend_scissor = to_scissor(
9345                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
9346                            current_target_size.0 as u32,
9347                            current_target_size.1 as u32,
9348                        );
9349                        self.emit_isolated_image(
9350                            draw_rect,
9351                            handle,
9352                            uv_rect,
9353                            tint,
9354                            tile,
9355                            filter,
9356                            blend,
9357                            current_transform,
9358                            &mut current_pass,
9359                            &active_clips,
9360                            &mut passes,
9361                            &mut flatten_id_head,
9362                            &mut next_pass_id,
9363                            &mut flatten_ids_used,
9364                            blend_scissor,
9365                            encoder,
9366                            fb_w,
9367                            fb_h,
9368                        );
9369                    }
9370                    continue;
9371                }
9372                let (ndc_center, fwd_mat) = rect_to_instance_ndc(
9373                    draw_rect,
9374                    current_transform,
9375                    current_target_size.0,
9376                    current_target_size.1,
9377                );
9378                if is_nv12 {
9379                    if image_run.is_some() {
9380                        flush_image_runs!();
9381                    }
9382                    let (uv_x_offset, uv_y_offset) =
9383                        if let Some(ImageTex::Nv12 {
9384                            w, h, color_info, ..
9385                        }) = self.images.get(&handle)
9386                        {
9387                            let chroma_w = w.div_ceil(2).max(1) as f32;
9388                            let chroma_h = h.div_ceil(2).max(1) as f32;
9389                            match color_info.chroma_siting {
9390                                ChromaSiting::Center => (0.0, 0.0),
9391                                ChromaSiting::Left => (-0.5 / chroma_w, 0.0),
9392                                ChromaSiting::TopLeft => (-0.5 / chroma_w, -0.5 / chroma_h),
9393                            }
9394                        } else {
9395                            (0.0, 0.0)
9396                        };
9397                    let inst = Nv12Instance {
9398                        xywh: ndc_center,
9399                        uv: uv_rect,
9400                        color: tint.to_linear(),
9401                        uv_x_offset,
9402                        uv_y_offset,
9403                        fwd_mat,
9404                    };
9405                    if nv12_run
9406                        .as_ref()
9407                        .is_some_and(|(run_handle, run_filter, run_blend, _)| {
9408                            *run_handle != handle || *run_filter != filter || *run_blend != blend
9409                        })
9410                    {
9411                        flush_image_runs!();
9412                    }
9413                    match &mut nv12_run {
9414                        Some((run_handle, run_filter, run_blend, instances))
9415                            if *run_handle == handle
9416                                && *run_filter == filter
9417                                && *run_blend == blend =>
9418                        {
9419                            instances.push(inst)
9420                        }
9421                        _ => nv12_run = Some((handle, filter, blend, vec![inst])),
9422                    }
9423                } else {
9424                    if nv12_run.is_some() {
9425                        flush_image_runs!();
9426                    }
9427                    let inst = GlyphInstance {
9428                        xywh: ndc_center,
9429                        uv: uv_rect,
9430                        color: tint.to_linear(),
9431                        fwd_mat,
9432                    };
9433                    if image_run.as_ref().is_some_and(
9434                        |(run_handle, run_filter, run_tile, run_blend, _)| {
9435                            *run_handle != handle
9436                                || *run_filter != filter
9437                                || *run_tile != tile
9438                                || *run_blend != blend
9439                        },
9440                    ) {
9441                        flush_image_runs!();
9442                    }
9443                    match &mut image_run {
9444                        Some((run_handle, run_filter, run_tile, run_blend, instances))
9445                            if *run_handle == handle
9446                                && *run_filter == filter
9447                                && *run_tile == tile
9448                                && *run_blend == blend =>
9449                        {
9450                            instances.push(inst)
9451                        }
9452                        _ => image_run = Some((handle, filter, tile, blend, vec![inst])),
9453                    }
9454                }
9455                continue;
9456            }
9457
9458            match node {
9459                SceneNode::Rect {
9460                    rect,
9461                    brush,
9462                    radius,
9463                } => {
9464                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9465                    if !visible(affine_aabb(current_transform, rect), clip) {
9466                        continue;
9467                    }
9468                    flush_if_prim_changed!("rect", &self.rects);
9469                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9470                        *rect,
9471                        current_transform,
9472                        current_target_size.0,
9473                        current_target_size.1,
9474                    );
9475                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9476                        brush_to_shape_fields(brush, rect, current_transform);
9477                    batch.rects.push(RectInstance {
9478                        xywh: ndc,
9479                        radii: radius.map(|r| r.0),
9480                        color0,
9481                        color1,
9482                        fwd_mat,
9483                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9484                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9485                        _pad: [0.0; 3],
9486                    });
9487                }
9488                SceneNode::Border {
9489                    rect,
9490                    brush,
9491                    width,
9492                    radius,
9493                } => {
9494                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9495                    if !visible(affine_aabb(current_transform, rect), clip) {
9496                        continue;
9497                    }
9498                    flush_if_prim_changed!("border", &self.borders);
9499                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9500                        *rect,
9501                        current_transform,
9502                        current_target_size.0,
9503                        current_target_size.1,
9504                    );
9505                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9506                        brush_to_shape_fields(brush, rect, current_transform);
9507                    batch.borders.push(BorderInstance {
9508                        xywh: ndc,
9509                        radii: radius.map(|r| r.0),
9510                        color0,
9511                        color1,
9512                        fwd_mat,
9513                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9514                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9515                        stroke: width.0,
9516                        _pad: [0.0; 2],
9517                    });
9518                }
9519                SceneNode::Ellipse { rect, brush } => {
9520                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9521                    if !visible(affine_aabb(current_transform, rect), clip) {
9522                        continue;
9523                    }
9524                    flush_if_prim_changed!("ellipse", &self.ellipses);
9525                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9526                        *rect,
9527                        current_transform,
9528                        current_target_size.0,
9529                        current_target_size.1,
9530                    );
9531                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9532                        brush_to_shape_fields(brush, rect, current_transform);
9533                    batch.ellipses.push(EllipseInstance {
9534                        xywh: ndc,
9535                        color0,
9536                        color1,
9537                        fwd_mat,
9538                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9539                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9540                        _pad: [0.0; 1],
9541                    });
9542                }
9543                SceneNode::EllipseBorder { rect, brush, width } => {
9544                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9545                    let cull_pad = width.0.abs() * 0.5 + 2.0;
9546                    let cull_rect = repose_core::Rect {
9547                        x: rect.x - cull_pad,
9548                        y: rect.y - cull_pad,
9549                        w: rect.w + cull_pad * 2.0,
9550                        h: rect.h + cull_pad * 2.0,
9551                    };
9552                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9553                        continue;
9554                    }
9555                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
9556                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9557                        *rect,
9558                        current_transform,
9559                        current_target_size.0,
9560                        current_target_size.1,
9561                    );
9562                    let pad_px = width.0 * 0.5 + 2.0;
9563                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9564                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9565                        brush_to_shape_fields(brush, rect, current_transform);
9566                    batch.e_borders.push(EllipseBorderInstance {
9567                        xywh: ndc,
9568                        color0,
9569                        color1,
9570                        fwd_mat,
9571                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9572                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9573                        stroke: width.0,
9574                        pad,
9575                        _pad: 0.0,
9576                    });
9577                }
9578                SceneNode::Arc {
9579                    rect,
9580                    start_angle,
9581                    sweep_angle,
9582                    stroke_width,
9583                    brush,
9584                    cap,
9585                } => {
9586                    if !start_angle.is_finite()
9587                        || !sweep_angle.is_finite()
9588                        || !stroke_width.0.is_finite()
9589                        || !rect.x.is_finite()
9590                        || !rect.y.is_finite()
9591                        || !rect.w.is_finite()
9592                        || !rect.h.is_finite()
9593                        || rect.w <= 0.0
9594                        || rect.h <= 0.0
9595                        || stroke_width.0 <= 0.0
9596                        || sweep_angle.abs() <= 1e-6
9597                    {
9598                        continue;
9599                    }
9600                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9601                    let cull_pad = stroke_width.0.abs() * 0.5 + 2.0;
9602                    let cull_rect = repose_core::Rect {
9603                        x: rect.x - cull_pad,
9604                        y: rect.y - cull_pad,
9605                        w: rect.w + cull_pad * 2.0,
9606                        h: rect.h + cull_pad * 2.0,
9607                    };
9608                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9609                        continue;
9610                    }
9611                    flush_if_prim_changed!("arc", &self.arcs);
9612                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9613                        *rect,
9614                        current_transform,
9615                        current_target_size.0.max(1.0),
9616                        current_target_size.1.max(1.0),
9617                    );
9618                    let pad_px = stroke_width.0 * 0.5 + 2.0;
9619                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9620                    let cap_val = match cap {
9621                        StrokeCap::Butt => 0.0,
9622                        StrokeCap::Round => 1.0,
9623                        StrokeCap::Square => 2.0,
9624                    };
9625                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9626                        brush_to_shape_fields(brush, rect, current_transform);
9627                    let (start, sweep) = if *sweep_angle < 0.0 {
9628                        (*start_angle + *sweep_angle, -*sweep_angle)
9629                    } else {
9630                        (*start_angle, *sweep_angle)
9631                    };
9632                    batch.arcs.push(ArcInstance {
9633                        xywh: ndc,
9634                        color0,
9635                        color1,
9636                        fwd_mat,
9637                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9638                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, cap_val as u32),
9639                        start_angle: start,
9640                        sweep_angle: sweep,
9641                        stroke: stroke_width.0,
9642                        pad,
9643                        _pad: 0.0,
9644                    });
9645                }
9646                SceneNode::Text {
9647                    rect,
9648                    text,
9649                    color,
9650                    size,
9651                    style,
9652                } => {
9653                    let TextPaintStyle {
9654                        font_family,
9655                        font_weight,
9656                        font_style,
9657                        text_decoration,
9658                        letter_spacing,
9659                        font_variation_settings,
9660                        baseline_shift,
9661                        draw_style,
9662                        ..
9663                    } = style;
9664                    let px = size.0;
9665                    if !px.is_finite()
9666                        || px <= 0.0
9667                        || !rect.x.is_finite()
9668                        || !rect.y.is_finite()
9669                        || !rect.w.is_finite()
9670                        || !rect.h.is_finite()
9671                    {
9672                        continue;
9673                    }
9674                    let margin = px * 4.0 + letter_spacing.0.abs() * 2.0;
9675                    let text_bounds = repose_core::Rect {
9676                        x: rect.x - margin,
9677                        y: rect.y - margin,
9678                        w: rect.w + margin * 2.0,
9679                        h: rect.h + margin * 2.0,
9680                    };
9681                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9682                    if !visible(affine_aabb(current_transform, &text_bounds), clip) {
9683                        continue;
9684                    }
9685                    flush_batch!(); // flush any prior primitives
9686
9687                    let lh_ratio = rect.h / px;
9688                    let fw = font_weight.0;
9689                    let fs = if *font_style == FontStyle::Italic {
9690                        1
9691                    } else {
9692                        0
9693                    };
9694                    let shaped = self.shape_text_cached(
9695                        text,
9696                        px,
9697                        lh_ratio,
9698                        *font_family,
9699                        fw,
9700                        fs,
9701                        letter_spacing.0,
9702                        font_variation_settings.as_ref(),
9703                    );
9704                    let baseline_y = shaped.glyphs.first().map(|g| rect.y + g.y);
9705
9706                    let fwd = forward_rs_mat(current_transform);
9707                    let has_linear = fwd != [1.0, 0.0, 0.0, 1.0];
9708
9709                    let lin = current_transform.linear();
9710                    let tr_x = current_transform.translate_x;
9711                    let tr_y = current_transform.translate_y;
9712
9713                    let make_glyph_instance =
9714                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 4]) {
9715                            if has_linear {
9716                                let gc_x = gx + gw * 0.5;
9717                                let gc_y = gy + gh * 0.5;
9718                                let wc_x = lin[0] * gc_x + lin[1] * gc_y + tr_x;
9719                                let wc_y = lin[2] * gc_x + lin[3] * gc_y + tr_y;
9720                                let ww = gw * current_transform.scale_x;
9721                                let wh = gh * current_transform.scale_y;
9722                                let ex = fwd[0].abs() * ww * 0.5 + fwd[1].abs() * wh * 0.5;
9723                                let ey = fwd[2].abs() * ww * 0.5 + fwd[3].abs() * wh * 0.5;
9724                                let ndc_tl = to_ndc(
9725                                    wc_x - ex,
9726                                    wc_y - ey,
9727                                    ex * 2.0,
9728                                    ey * 2.0,
9729                                    current_target_size.0,
9730                                    current_target_size.1,
9731                                );
9732                                let ndc = [
9733                                    ndc_tl[0] + ndc_tl[2] * 0.5,
9734                                    ndc_tl[1] + ndc_tl[3] * 0.5,
9735                                    ndc_tl[2],
9736                                    ndc_tl[3],
9737                                ];
9738                                (ndc, fwd)
9739                            } else {
9740                                let (sx, sy) = if current_transform.scale_x == 1.0
9741                                    && current_transform.scale_y == 1.0
9742                                {
9743                                    (gx.round(), gy.round())
9744                                } else {
9745                                    (gx, gy)
9746                                };
9747                                rect_to_instance_ndc(
9748                                    repose_core::Rect {
9749                                        x: sx,
9750                                        y: sy,
9751                                        w: gw,
9752                                        h: gh,
9753                                    },
9754                                    current_transform,
9755                                    current_target_size.0,
9756                                    current_target_size.1,
9757                                )
9758                            }
9759                        };
9760
9761                    let baseline_shift_y: f32 = px * baseline_shift.0;
9762
9763                    let (
9764                        draws_fill,
9765                        is_stroke,
9766                        stroke_width,
9767                        stroke_cap,
9768                        stroke_join,
9769                        stroke_miter,
9770                        stroke_path_effect,
9771                        stroke_color,
9772                    ) = match draw_style {
9773                        repose_core::DrawStyle::Stroke {
9774                            width,
9775                            cap,
9776                            join,
9777                            miter,
9778                            path_effect,
9779                        } => (
9780                            false,
9781                            true,
9782                            *width,
9783                            *cap,
9784                            *join,
9785                            *miter,
9786                            path_effect.clone(),
9787                            None,
9788                        ),
9789                        repose_core::DrawStyle::FillAndStroke {
9790                            width,
9791                            cap,
9792                            join,
9793                            miter,
9794                            path_effect,
9795                            outline_color,
9796                        } => (
9797                            true,
9798                            true,
9799                            *width,
9800                            *cap,
9801                            *join,
9802                            *miter,
9803                            path_effect.clone(),
9804                            // The outline is part of this text's appearance,
9805                            // so it fades with the text alpha like the fill.
9806                            // Without this a faded label keeps a solid halo.
9807                            outline_color.map(|c| {
9808                                c.with_alpha_f32(
9809                                    f32::from(c.3) * f32::from(color.3) / (255.0 * 255.0),
9810                                )
9811                            }),
9812                        ),
9813                        _ => (
9814                            true,
9815                            false,
9816                            0.0,
9817                            repose_core::StrokeCap::Butt,
9818                            repose_core::StrokeJoin::Miter,
9819                            4.0,
9820                            None,
9821                            None,
9822                        ),
9823                    };
9824                    // A contrasting outline reads as a halo, so it goes under
9825                    // the fill; a same-colour stroke is faux-bold and stays on
9826                    // top.
9827                    let outline_under = stroke_color.is_some();
9828                    let stroke_tess_key = if is_stroke {
9829                        Some(slug::StrokeTessKey::new(
9830                            stroke_width,
9831                            stroke_cap,
9832                            stroke_join,
9833                            stroke_miter,
9834                            &stroke_path_effect,
9835                        ))
9836                    } else {
9837                        None
9838                    };
9839
9840                    let mut legacy_shaped = None;
9841                    for (glyph_index, sg) in shaped.glyphs.iter().enumerate() {
9842                        if self.slug_enabled {
9843                            let ck = &sg.cache_key;
9844                            let color_linear = color.to_linear();
9845                            let stroke_color_linear = stroke_color.unwrap_or(*color).to_linear();
9846                            let glyph_pos = (rect.x + sg.x, rect.y + sg.y + baseline_shift_y);
9847                            let fill_key = draws_fill.then(|| {
9848                                Self::slug_draw_key(
9849                                    ck,
9850                                    true,
9851                                    None,
9852                                    current_transform,
9853                                    glyph_pos,
9854                                    px,
9855                                    current_target_size,
9856                                    color_linear,
9857                                )
9858                            });
9859                            let stroke_key = is_stroke.then(|| {
9860                                Self::slug_draw_key(
9861                                    ck,
9862                                    false,
9863                                    stroke_tess_key.as_ref(),
9864                                    current_transform,
9865                                    glyph_pos,
9866                                    px,
9867                                    current_target_size,
9868                                    stroke_color_linear,
9869                                )
9870                            });
9871
9872                            // The draw cache outlives the frame, so probing both keys up
9873                            // front lets a steady-state glyph clone neither buffer.
9874                            let fill_cache = fill_key
9875                                .as_ref()
9876                                .and_then(|k| self.get_cached_slug_vertices(k));
9877                            let stroke_cache = stroke_key
9878                                .as_ref()
9879                                .and_then(|k| self.get_cached_slug_vertices(k));
9880                            let fill_miss = fill_key.is_some() && fill_cache.is_none();
9881                            let stroke_miss = stroke_key.is_some() && stroke_cache.is_none();
9882                            if (fill_key.is_some() || stroke_key.is_some())
9883                                && !fill_miss
9884                                && !stroke_miss
9885                            {
9886                                let mut push = |cached: &Option<Arc<[slug::TessVertex]>>| {
9887                                    if let Some(vertices) = cached {
9888                                        slug_verts_local.extend_from_slice(vertices);
9889                                    }
9890                                };
9891                                if outline_under {
9892                                    push(&stroke_cache);
9893                                    push(&fill_cache);
9894                                } else {
9895                                    push(&fill_cache);
9896                                    push(&stroke_cache);
9897                                }
9898                                continue;
9899                            }
9900
9901                            let outline = self.cached_glyph_outline(ck);
9902                            if let Some(commands) = outline.as_ref() {
9903                                let font_size_px = f32::from_bits(ck.font_size_bits);
9904                                if draws_fill {
9905                                    self.slug_cache.get_or_insert(
9906                                        ck.clone(),
9907                                        font_size_px,
9908                                        commands.as_ref(),
9909                                    );
9910                                }
9911                                if is_stroke {
9912                                    self.slug_cache.get_or_insert_stroke(
9913                                        ck.clone(),
9914                                        font_size_px,
9915                                        commands.as_ref(),
9916                                        stroke_width,
9917                                        stroke_cap,
9918                                        stroke_join,
9919                                        stroke_miter,
9920                                        &stroke_path_effect,
9921                                    );
9922                                }
9923                            }
9924
9925                            if let Some(entry) = self.slug_cache.get(ck) {
9926                                let ox = rect.x + sg.x;
9927                                let oy = rect.y + sg.y + baseline_shift_y;
9928                                let tf = |x: f32, y: f32| -> (f32, f32) {
9929                                    if has_linear {
9930                                        (
9931                                            lin[0] * x + lin[1] * y + current_transform.translate_x,
9932                                            lin[2] * x + lin[3] * y + current_transform.translate_y,
9933                                        )
9934                                    } else {
9935                                        (
9936                                            x * current_transform.scale_x
9937                                                + current_transform.translate_x,
9938                                            y * current_transform.scale_y
9939                                                + current_transform.translate_y,
9940                                        )
9941                                    }
9942                                };
9943                                let tw = current_target_size.0;
9944                                let th = current_target_size.1;
9945                                let fill_vertices = if draws_fill && fill_cache.is_none() {
9946                                    entry.fill_vertices.clone().unwrap_or_default()
9947                                } else {
9948                                    Vec::new()
9949                                };
9950                                let stroke_vertices = if stroke_cache.is_none() {
9951                                    stroke_tess_key
9952                                        .as_ref()
9953                                        .and_then(|key| entry.stroke_variants.get(key))
9954                                        .map(|variant| variant.vertices.clone())
9955                                        .unwrap_or_default()
9956                                } else {
9957                                    Vec::new()
9958                                };
9959                                let mut emit =
9960                                    |verts: &Vec<[f32; 2]>,
9961                                     key: &Option<SlugDrawKey>,
9962                                     cached: &Option<Arc<[slug::TessVertex]>>,
9963                                     vertex_color: [f32; 4]| {
9964                                        if let Some(vertices) = cached {
9965                                            slug_verts_local.extend_from_slice(vertices);
9966                                            return;
9967                                        }
9968                                        let Some(key) = key.clone() else {
9969                                            return;
9970                                        };
9971                                        let transformed: Vec<slug::TessVertex> = verts
9972                                            .iter()
9973                                            .map(|v| {
9974                                                let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
9975                                                slug::TessVertex {
9976                                                    ndc_pos: [
9977                                                        sx / tw * 2.0 - 1.0,
9978                                                        -(sy / th) * 2.0 + 1.0,
9979                                                    ],
9980                                                    color: vertex_color,
9981                                                }
9982                                            })
9983                                            .collect();
9984                                        let vertices = self.cached_slug_vertices(key, transformed);
9985                                        slug_verts_local.extend_from_slice(&vertices);
9986                                    };
9987                                // A contrasting outline reads as a halo and
9988                                // goes under the fill; a same-colour stroke is
9989                                // faux-bold and stays on top.
9990                                if outline_under {
9991                                    emit(
9992                                        &stroke_vertices,
9993                                        &stroke_key,
9994                                        &stroke_cache,
9995                                        stroke_color_linear,
9996                                    );
9997                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
9998                                } else {
9999                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
10000                                    emit(
10001                                        &stroke_vertices,
10002                                        &stroke_key,
10003                                        &stroke_cache,
10004                                        stroke_color_linear,
10005                                    );
10006                                }
10007                                continue;
10008                            }
10009                        }
10010
10011                        if !draws_fill {
10012                            continue;
10013                        }
10014                        if legacy_shaped.is_none() {
10015                            legacy_shaped = Some(self.legacy_shape_text_cached(
10016                                text,
10017                                px,
10018                                lh_ratio,
10019                                *font_family,
10020                                fw,
10021                                fs,
10022                                letter_spacing.0,
10023                                font_variation_settings.as_ref(),
10024                            ));
10025                        }
10026                        let Some(legacy_glyph) = legacy_shaped
10027                            .as_ref()
10028                            .and_then(|glyphs| glyphs.get(glyph_index))
10029                        else {
10030                            continue;
10031                        };
10032                        let gx = rect.x + legacy_glyph.x + legacy_glyph.bearing_x;
10033                        let gy =
10034                            rect.y + legacy_glyph.y - legacy_glyph.bearing_y + baseline_shift_y;
10035                        if let Some(info) =
10036                            self.upload_glyph_color(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10037                        {
10038                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10039                            batch.colors.push(GlyphInstance {
10040                                xywh: ndc,
10041                                uv: [info.u0, info.v1, info.u1, info.v0],
10042                                color: color.to_linear(),
10043                                fwd_mat,
10044                            });
10045                        } else if let Some(info) =
10046                            self.upload_glyph_mask(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10047                        {
10048                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10049                            batch.masks.push(GlyphInstance {
10050                                xywh: ndc,
10051                                uv: [info.u0, info.v1, info.u1, info.v0],
10052                                color: color.to_linear(),
10053                                fwd_mat,
10054                            });
10055                        }
10056                    }
10057
10058                    // Upload slug vertices if any
10059                    if !slug_verts_local.is_empty() {
10060                        let bytes = bytemuck::cast_slice(&slug_verts_local);
10061                        let byte_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
10062                        let Ok(count) = u32::try_from(slug_verts_local.len()) else {
10063                            slug_verts_local.clear();
10064                            continue;
10065                        };
10066                        if self
10067                            .slug_ring
10068                            .grow_to_fit(&self.device, encoder, byte_len)
10069                            .is_ok()
10070                            && let Ok(off) = self.slug_ring.alloc_write(&self.queue, bytes)
10071                        {
10072                            current_pass
10073                                .cmds
10074                                .push(Cmd::GlyphsVector { off, cnt: count });
10075                        }
10076                        slug_verts_local.clear();
10077                    }
10078
10079                    // Text decoration: underline / strikethrough
10080                    if (text_decoration.underline || text_decoration.strikethrough)
10081                        && let Some(baseline_y) = baseline_y
10082                    {
10083                        flush_batch!();
10084                        current_prim = Some("rect");
10085                        let deco_color = text_decoration.color.unwrap_or(*color);
10086                        let thickness = (px * 0.07).max(1.0);
10087
10088                        if text_decoration.underline {
10089                            let dy = baseline_y + px * 0.1;
10090                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10091                                repose_core::Rect {
10092                                    x: rect.x,
10093                                    y: dy,
10094                                    w: rect.w,
10095                                    h: thickness,
10096                                },
10097                                current_transform,
10098                                current_target_size.0,
10099                                current_target_size.1,
10100                            );
10101                            batch.rects.push(RectInstance {
10102                                xywh: ndc,
10103                                radii: [0.0; 4],
10104                                color0: deco_color.to_linear(),
10105                                color1: [0.0; 4],
10106                                fwd_mat,
10107                                grad: [0.0; 4],
10108                                bits: 0,
10109                                _pad: [0.0; 3],
10110                            });
10111                        }
10112                        if text_decoration.strikethrough {
10113                            let sy = baseline_y - px * 0.3;
10114                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10115                                repose_core::Rect {
10116                                    x: rect.x,
10117                                    y: sy,
10118                                    w: rect.w,
10119                                    h: thickness,
10120                                },
10121                                current_transform,
10122                                current_target_size.0,
10123                                current_target_size.1,
10124                            );
10125                            batch.rects.push(RectInstance {
10126                                xywh: ndc,
10127                                radii: [0.0; 4],
10128                                color0: deco_color.to_linear(),
10129                                color1: [0.0; 4],
10130                                fwd_mat,
10131                                grad: [0.0; 4],
10132                                bits: 0,
10133                                _pad: [0.0; 3],
10134                            });
10135                        }
10136                    }
10137                }
10138                SceneNode::Coverage {
10139                    rect,
10140                    handle,
10141                    color,
10142                } => {
10143                    flush_batch!();
10144                    // Unknown handles are skipped (same policy as images);
10145                    // the lookup also marks the tile used for eviction.
10146                    let Some((tile_w, tile_h)) = self.coverage_dimensions(*handle) else {
10147                        log::warn!("Coverage handle {handle} not found");
10148                        continue;
10149                    };
10150                    // The tile composites at its registered size; `rect`
10151                    // positions its top-left.
10152                    let draw_rect = repose_core::Rect {
10153                        x: rect.x,
10154                        y: rect.y,
10155                        w: tile_w as f32,
10156                        h: tile_h as f32,
10157                    };
10158                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10159                    if !visible(affine_aabb(current_transform, &draw_rect), clip) {
10160                        continue;
10161                    }
10162                    let (ndc_center, fwd_mat) = rect_to_instance_ndc(
10163                        draw_rect,
10164                        current_transform,
10165                        current_target_size.0,
10166                        current_target_size.1,
10167                    );
10168                    let inst = GlyphInstance {
10169                        xywh: ndc_center,
10170                        uv: [0.0, 1.0, 1.0, 0.0],
10171                        color: color.to_linear(),
10172                        fwd_mat,
10173                    };
10174                    if let Some((off, _)) =
10175                        self.glyph_color
10176                            .upload(&self.device, &self.queue, encoder, &[inst])
10177                    {
10178                        current_pass.cmds.push(Cmd::Coverage {
10179                            off,
10180                            cnt: 1,
10181                            handle: *handle,
10182                        });
10183                    }
10184                }
10185                SceneNode::PushClip { rect, radius, op } => {
10186                    flush_batch!();
10187
10188                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
10189                    let t_identity = Transform::identity();
10190                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10191                    let transformed = affine_aabb(current_transform, rect);
10192                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10193                    let next_scissor = if is_diff {
10194                        top
10195                    } else {
10196                        intersect(top, transformed)
10197                    };
10198                    scissor_stack.push(next_scissor);
10199                    let scissor = to_scissor(
10200                        &next_scissor,
10201                        current_target_size.0 as u32,
10202                        current_target_size.1 as u32,
10203                    );
10204                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10205                    let mut blocked =
10206                        current_pass.active_scissor.is_none() || (has_inverse && !is_diff);
10207                    if has_inverse && is_diff {
10208                        log::error!(
10209                            "unsupported clip nesting: Difference cannot contain a Difference clip"
10210                        );
10211                        blocked = true;
10212                    }
10213                    let has_area = transformed.x.is_finite()
10214                        && transformed.y.is_finite()
10215                        && transformed.w.is_finite()
10216                        && transformed.h.is_finite()
10217                        && transformed.w > 0.0
10218                        && transformed.h > 0.0;
10219                    let mut applied = false;
10220                    let mut off = 0;
10221                    if !blocked && has_area && (is_diff || scissor.2 > 0 && scissor.3 > 0) {
10222                        let clip_ndc_tl = to_ndc(
10223                            transformed.x,
10224                            transformed.y,
10225                            transformed.w,
10226                            transformed.h,
10227                            current_target_size.0,
10228                            current_target_size.1,
10229                        );
10230                        let inst = ClipInstance {
10231                            xywh: [
10232                                clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
10233                                clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
10234                                clip_ndc_tl[2],
10235                                clip_ndc_tl[3],
10236                            ],
10237                            radii: radius.map(|r| r.0),
10238                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10239                        };
10240                        let bytes = bytemuck::bytes_of(&inst);
10241                        if self
10242                            .clip_ring
10243                            .grow_to_fit(
10244                                &self.device,
10245                                encoder,
10246                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
10247                            )
10248                            .is_ok()
10249                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
10250                        {
10251                            off = new_off;
10252                            applied = true;
10253                            current_pass.cmds.push(Cmd::ClipPush {
10254                                off,
10255                                cnt: 1,
10256                                scissor,
10257                                difference: is_diff,
10258                                applied,
10259                            });
10260                        } else {
10261                            blocked = !is_diff;
10262                        }
10263                    } else if !is_diff {
10264                        blocked = true;
10265                    }
10266                    current_pass.active_scissor = if blocked {
10267                        None
10268                    } else {
10269                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10270                    };
10271                    active_clips.push(ActiveClip::Rect {
10272                        off,
10273                        cnt: u32::from(applied),
10274                        rect: transformed,
10275                        radii: radius.map(|r| r.0),
10276                        scissor,
10277                        difference: is_diff,
10278                        applied,
10279                        blocked,
10280                    });
10281                }
10282                SceneNode::PopClip => {
10283                    flush_batch!();
10284
10285                    if scissor_stack.is_empty() || active_clips.is_empty() {
10286                        log::error!("PopClip does not match an active clip");
10287                    } else {
10288                        scissor_stack.pop();
10289                    }
10290                    let clip = active_clips.pop();
10291                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10292                    let scissor = to_scissor(
10293                        &top,
10294                        current_target_size.0 as u32,
10295                        current_target_size.1 as u32,
10296                    );
10297                    current_pass.active_scissor =
10298                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10299                    match clip {
10300                        Some(ActiveClip::Rect {
10301                            off,
10302                            cnt,
10303                            scissor: stencil_scissor,
10304                            difference,
10305                            applied,
10306                            ..
10307                        }) if applied && cnt > 0 => {
10308                            current_pass.cmds.push(Cmd::ClipPop {
10309                                off,
10310                                cnt,
10311                                scissor,
10312                                stencil_scissor,
10313                                difference,
10314                                applied: true,
10315                            });
10316                        }
10317                        Some(ActiveClip::Vector { .. }) => {
10318                            log::error!("PopClip matched a vector clip");
10319                        }
10320                        None => {}
10321                        _ => {}
10322                    }
10323                }
10324                SceneNode::Shadow {
10325                    rect,
10326                    radius,
10327                    elevation: _,
10328                    color,
10329                } => {
10330                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10331                    if !visible(affine_aabb(current_transform, rect), clip) {
10332                        continue;
10333                    }
10334                    flush_if_prim_changed!("rect", &self.rects);
10335                    let (ndc, fwd_mat) = rect_to_instance_ndc(
10336                        *rect,
10337                        current_transform,
10338                        current_target_size.0,
10339                        current_target_size.1,
10340                    );
10341                    let (_brush_type, color0, _color1, _grad_p0, _grad_p1) =
10342                        brush_to_instance_fields(&Brush::Solid(*color));
10343                    batch.rects.push(RectInstance {
10344                        xywh: ndc,
10345                        radii: radius.map(|r| r.0),
10346                        color0,
10347                        color1: [0.0; 4],
10348                        fwd_mat,
10349                        grad: [0.0; 4],
10350                        bits: 0,
10351                        _pad: [0.0; 3],
10352                    });
10353                }
10354                SceneNode::PushTransform { transform } => {
10355                    flush_batch!(); // flush before transform change
10356                    if transform.has_perspective() {
10357                        // True perspective cannot ride the affine fast path:
10358                        // flatten the subtree into an offscreen layer and
10359                        // composite it back projectively (CSS-style). See
10360                        // `push_perspective_layer`.
10361                        let top = *transform_stack.last().unwrap_or(&t_identity);
10362                        self.push_perspective_layer(
10363                            *transform,
10364                            top,
10365                            &mut transform_stack,
10366                            &mut scissor_stack,
10367                            &mut root_clip_rect,
10368                            &mut current_target_size,
10369                            &mut current_pass,
10370                            &mut active_clips,
10371                            &mut passes,
10372                            &mut target_stack,
10373                            &mut flatten_stack,
10374                            &mut flatten_id_head,
10375                            &mut next_pass_id,
10376                            &mut flatten_ids_used,
10377                            encoder,
10378                        );
10379                    } else {
10380                        let combined = current_transform.combine(transform);
10381                        transform_stack.push(combined);
10382                    }
10383                }
10384                SceneNode::PopTransform => {
10385                    flush_batch!(); // flush before transform change
10386                    if let Some(rec) = flatten_stack.last() {
10387                        // A flatten level closes when the stack is back to the
10388                        // two entries this flatten pushed (stripped transform +
10389                        // layer-local shift); deeper plain pushes close first.
10390                        if transform_stack.len() == rec.stack_len + 2 {
10391                            let rec = flatten_stack.pop().expect("checked above");
10392                            transform_stack.pop();
10393                            transform_stack.pop();
10394                            self.pop_perspective_layer(
10395                                rec,
10396                                &mut scissor_stack,
10397                                &mut root_clip_rect,
10398                                &mut current_target_size,
10399                                &mut current_pass,
10400                                &mut active_clips,
10401                                &mut passes,
10402                                &mut target_stack,
10403                                &mut next_pass_id,
10404                                encoder,
10405                            );
10406                            continue;
10407                        }
10408                    }
10409                    transform_stack.pop();
10410                }
10411                SceneNode::BeginLayer {
10412                    rect,
10413                    layer_id,
10414                    alpha,
10415                    blur_radius_x,
10416                    blur_radius_y,
10417                    rectangle_edge,
10418                } => {
10419                    flush_batch!();
10420                    let width_f = rect.w.round();
10421                    let height_f = rect.h.round();
10422                    let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
10423                    if !width_f.is_finite()
10424                        || !height_f.is_finite()
10425                        || width_f < 1.0
10426                        || height_f < 1.0
10427                        || width_f > max_dimension
10428                        || height_f > max_dimension
10429                    {
10430                        log::warn!("BeginLayer dimensions are invalid");
10431                        continue;
10432                    }
10433                    let width = width_f as u32;
10434                    let height = height_f as u32;
10435                    let parent_scissors =
10436                        std::mem::replace(&mut scissor_stack, Vec::with_capacity(8));
10437                    let parent_root_clip = std::mem::replace(
10438                        &mut root_clip_rect,
10439                        repose_core::Rect {
10440                            x: 0.0,
10441                            y: 0.0,
10442                            w: width as f32,
10443                            h: height as f32,
10444                        },
10445                    );
10446                    scissor_stack.push(root_clip_rect);
10447                    let layer_pass_id = next_pass_id;
10448                    next_pass_id += 1;
10449                    let parent_clips = std::mem::take(&mut active_clips);
10450                    let mut layer_pass = Pass {
10451                        id: layer_pass_id,
10452                        target: PassTarget::Layer(*layer_id),
10453                        initial_scissor: (0, 0, width, height),
10454                        active_scissor: Some((0, 0, width, height)),
10455                        clear_color: Some([0.0, 0.0, 0.0, 0.0]),
10456                        cmds: Vec::new(),
10457                    };
10458                    let parent_transform = *current_transform;
10459                    active_clips = self.replay_active_clips(
10460                        &parent_clips,
10461                        &parent_transform,
10462                        (rect.x, rect.y),
10463                        (width as f32, height as f32),
10464                        &mut layer_pass,
10465                        encoder,
10466                    );
10467                    let saved = std::mem::replace(&mut current_pass, layer_pass);
10468                    if let Some(top) = transform_stack.last_mut() {
10469                        *top = Transform::identity();
10470                    }
10471                    layer_stack.push(LayerState {
10472                        layer_id: *layer_id,
10473                        parent_target: saved.target,
10474                        parent_scissors,
10475                        parent_root_clip,
10476                        parent_size: current_target_size,
10477                        parent_transform,
10478                        parent_scissor: saved.active_scissor,
10479                        parent_clips,
10480                        alpha: *alpha,
10481                        blur: (blur_radius_x.0, blur_radius_y.0),
10482                        rectangle_edge: *rectangle_edge,
10483                    });
10484                    passes.push(saved);
10485                    if self.get_or_create_layer(*layer_id, width, height, *rect, false) {
10486                        self.producer_layer_ids.insert(*layer_id);
10487                    }
10488                    current_target_size = (width as f32, height as f32);
10489                }
10490                SceneNode::EndLayer { layer_id } => {
10491                    flush_batch!();
10492                    if layer_stack.last().map(|state| state.layer_id) != Some(*layer_id) {
10493                        log::warn!("EndLayer {} does not match active layer", layer_id);
10494                        continue;
10495                    }
10496                    let state = layer_stack.pop().expect("checked above");
10497                    scissor_stack = state.parent_scissors;
10498                    root_clip_rect = state.parent_root_clip;
10499                    active_clips = state.parent_clips;
10500                    if let Some(top) = transform_stack.last_mut() {
10501                        *top = state.parent_transform;
10502                    }
10503                    current_target_size = state.parent_size;
10504                    let resumed_pass_id = next_pass_id;
10505                    next_pass_id += 1;
10506                    let saved = std::mem::replace(
10507                        &mut current_pass,
10508                        Pass {
10509                            id: resumed_pass_id,
10510                            target: state.parent_target,
10511                            initial_scissor: state.parent_scissor.unwrap_or((0, 0, 1, 1)),
10512                            active_scissor: state.parent_scissor,
10513                            clear_color: None,
10514                            cmds: Vec::new(),
10515                        },
10516                    );
10517                    passes.push(saved);
10518                    let Some(layer) = self.layer_pool.get(layer_id).cloned() else {
10519                        continue;
10520                    };
10521                    let layer_rect = repose_core::Rect {
10522                        x: layer.rect_px.0,
10523                        y: layer.rect_px.1,
10524                        w: layer.rect_px.2,
10525                        h: layer.rect_px.3,
10526                    };
10527                    let local_layer_rect = affine_aabb(&state.parent_transform, &layer_rect);
10528                    let (parent_width, parent_height) = state.parent_size;
10529                    let parent_clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10530                    let composite_rect = if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10531                        let pad_x = blur_pad(state.blur.0);
10532                        let pad_y = blur_pad(state.blur.1);
10533                        repose_core::Rect {
10534                            x: local_layer_rect.x - pad_x,
10535                            y: local_layer_rect.y - pad_y,
10536                            w: local_layer_rect.w + pad_x * 2.0,
10537                            h: local_layer_rect.h + pad_y * 2.0,
10538                        }
10539                    } else {
10540                        local_layer_rect
10541                    };
10542                    let shadow_follows = shadow_layer_ids.contains(layer_id);
10543                    if !visible(composite_rect, parent_clip) {
10544                        continue;
10545                    }
10546                    let mut composite = None;
10547                    if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10548                        flush_batch!();
10549                        let pad_x = blur_pad(state.blur.0);
10550                        let pad_y = blur_pad(state.blur.1);
10551                        let rect = repose_core::Rect {
10552                            x: local_layer_rect.x - pad_x,
10553                            y: local_layer_rect.y - pad_y,
10554                            w: local_layer_rect.w + pad_x * 2.0,
10555                            h: local_layer_rect.h + pad_y * 2.0,
10556                        };
10557                        let ndc =
10558                            to_ndc(rect.x, rect.y, rect.w, rect.h, parent_width, parent_height);
10559                        let edge_mode = if state.rectangle_edge { 0 } else { 1 };
10560                        let sigma = [blur_sigma(state.blur.0), blur_sigma(state.blur.1)];
10561                        if let Some(scratch_id) = self.emit_separable_blur(
10562                            *layer_id,
10563                            BLUR_SLOT_CONTENT,
10564                            sigma,
10565                            (pad_x, pad_y),
10566                            edge_mode,
10567                            false,
10568                            [0.0; 4],
10569                            &mut passes,
10570                            &mut current_pass,
10571                            &mut next_pass_id,
10572                            encoder,
10573                        ) {
10574                            let inst = BlurInstance {
10575                                xywh: [
10576                                    ndc[0] + ndc[2] * 0.5,
10577                                    ndc[1] + ndc[3] * 0.5,
10578                                    ndc[2],
10579                                    ndc[3],
10580                                ],
10581                                uv: [0.0, 0.0, 1.0, 1.0],
10582                                color: [1.0, 1.0, 1.0, state.alpha],
10583                                sigma_px: sigma,
10584                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10585                                axis: 1,
10586                                edge_mode,
10587                                shape: [0.0; 4],
10588                            };
10589                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10590                                composite = Some(Cmd::CompositeBlur {
10591                                    off,
10592                                    cnt: 1,
10593                                    layer_id: scratch_id,
10594                                });
10595                            }
10596                        }
10597                    }
10598                    // No scratch (transient budget exhausted) leaves the layer
10599                    // sharp rather than dropped.
10600                    if composite.is_none() {
10601                        let ndc = to_ndc(
10602                            local_layer_rect.x,
10603                            local_layer_rect.y,
10604                            local_layer_rect.w,
10605                            local_layer_rect.h,
10606                            parent_width,
10607                            parent_height,
10608                        );
10609                        let inst = GlyphInstance {
10610                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
10611                            uv: [0.0, 1.0, 1.0, 0.0],
10612                            color: [1.0, 1.0, 1.0, state.alpha],
10613                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10614                        };
10615                        composite = self
10616                            .glyph_color
10617                            .upload(&self.device, &self.queue, encoder, &[inst])
10618                            .map(|(off, cnt)| Cmd::CompositeLayer {
10619                                off,
10620                                cnt,
10621                                layer_id: *layer_id,
10622                            });
10623                    }
10624                    if let Some(command) = composite {
10625                        if shadow_follows {
10626                            pending_layer_composite = Some(command);
10627                        } else {
10628                            current_pass.cmds.push(command);
10629                        }
10630                    }
10631                }
10632                SceneNode::CompositeShadow {
10633                    layer_id,
10634                    blur_px,
10635                    offset_px,
10636                    color,
10637                    shape_px,
10638                } => {
10639                    let sigma = blur_sigma(blur_px.0);
10640                    let pad = blur_pad(blur_px.0);
10641                    let layer = self.layer_pool.get(layer_id).cloned();
10642                    if let Some(layer) = layer {
10643                        let layer_rect = repose_core::Rect {
10644                            x: layer.rect_px.0,
10645                            y: layer.rect_px.1,
10646                            w: layer.rect_px.2,
10647                            h: layer.rect_px.3,
10648                        };
10649                        let local_layer_rect =
10650                            affine_aabb(transform_stack.last().unwrap_or(&t_identity), &layer_rect);
10651                        let sx = local_layer_rect.x + offset_px.0.0 - pad;
10652                        let sy = local_layer_rect.y + offset_px.1.0 - pad;
10653                        let sw = local_layer_rect.w + pad * 2.0;
10654                        let sh = local_layer_rect.h + pad * 2.0;
10655                        let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10656                        if !visible(
10657                            repose_core::Rect {
10658                                x: sx,
10659                                y: sy,
10660                                w: sw,
10661                                h: sh,
10662                            },
10663                            clip,
10664                        ) {
10665                            flush_pending_layer!();
10666                            continue;
10667                        }
10668                        flush_batch!();
10669                        let shadow_color = color.to_linear();
10670                        let max_r = 0.5 * layer.rect_px.2.min(layer.rect_px.3).max(0.0);
10671                        let shape = shape_px.map(|r| r.0.clamp(0.0, max_r));
10672                        // Decal edges: a drop shadow's falloff lives outside the
10673                        // source's own bounds, so out-of-range taps must
10674                        // contribute nothing rather than clamp to the edge texel.
10675                        let scratch_id = self.emit_separable_blur(
10676                            *layer_id,
10677                            BLUR_SLOT_SHADOW,
10678                            [sigma, sigma],
10679                            (pad, pad),
10680                            1,
10681                            true,
10682                            shape,
10683                            &mut passes,
10684                            &mut current_pass,
10685                            &mut next_pass_id,
10686                            encoder,
10687                        );
10688                        if let Some(scratch_id) = scratch_id {
10689                            let ndc_tl = to_ndc(
10690                                sx,
10691                                sy,
10692                                sw,
10693                                sh,
10694                                current_target_size.0,
10695                                current_target_size.1,
10696                            );
10697                            let inst = BlurInstance {
10698                                xywh: [
10699                                    ndc_tl[0] + ndc_tl[2] * 0.5,
10700                                    ndc_tl[1] + ndc_tl[3] * 0.5,
10701                                    ndc_tl[2],
10702                                    ndc_tl[3],
10703                                ],
10704                                uv: [0.0, 0.0, 1.0, 1.0],
10705                                color: shadow_color,
10706                                sigma_px: [sigma, sigma],
10707                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10708                                axis: 1,
10709                                edge_mode: 1,
10710                                shape: [0.0; 4],
10711                            };
10712                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10713                                current_pass.cmds.push(Cmd::CompositeShadow {
10714                                    off,
10715                                    cnt: 1,
10716                                    layer_id: scratch_id,
10717                                });
10718                            }
10719                        }
10720                    }
10721                    flush_pending_layer!();
10722                }
10723                SceneNode::VectorMesh {
10724                    mesh,
10725                    transform,
10726                    paint,
10727                    clip: _,
10728                    blend,
10729                } => {
10730                    let affine = combine_mesh_affine(current_transform, *transform);
10731                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10732                    if !visible(mesh_aabb(mesh, affine), clip) {
10733                        continue;
10734                    }
10735                    flush_batch!();
10736                    let paint_alpha = match paint {
10737                        repose_core::PaintDesc::Linear {
10738                            start_color,
10739                            end_color,
10740                            ..
10741                        }
10742                        | repose_core::PaintDesc::Radial {
10743                            start_color,
10744                            end_color,
10745                            ..
10746                        }
10747                        | repose_core::PaintDesc::Sweep {
10748                            start_color,
10749                            end_color,
10750                            ..
10751                        } => Some(start_color.3.min(end_color.3) as f32 / 255.0),
10752                        repose_core::PaintDesc::LinearStops { stops, .. }
10753                        | repose_core::PaintDesc::RadialStops { stops, .. } => {
10754                            Some(stops.min_alpha())
10755                        }
10756                        _ => None,
10757                    };
10758                    let translucent_fixed_blend =
10759                        matches!(
10760                            blend,
10761                            repose_core::BlendMode::Add
10762                                | repose_core::BlendMode::Multiply
10763                                | repose_core::BlendMode::Screen
10764                                | repose_core::BlendMode::Darken
10765                                | repose_core::BlendMode::Lighten
10766                        ) && (mesh.vertices.iter().any(|vertex| vertex.color[3] < 0.9999)
10767                            || paint_alpha.is_some_and(|alpha| alpha < 0.9999));
10768                    if blend.needs_isolation() || translucent_fixed_blend {
10769                        let blend_scissor = to_scissor(
10770                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
10771                            current_target_size.0 as u32,
10772                            current_target_size.1 as u32,
10773                        );
10774                        self.emit_isolated_blend(
10775                            mesh.clone(),
10776                            *transform,
10777                            *paint,
10778                            *blend,
10779                            current_transform,
10780                            &mut current_pass,
10781                            &active_clips,
10782                            &mut passes,
10783                            &mut flatten_id_head,
10784                            &mut next_pass_id,
10785                            &mut flatten_ids_used,
10786                            blend_scissor,
10787                            encoder,
10788                            fb_w,
10789                            fb_h,
10790                        );
10791                    } else {
10792                        let t_identity = Transform::identity();
10793                        let current_transform = transform_stack.last().unwrap_or(&t_identity);
10794                        self.emit_vector_mesh(
10795                            current_transform,
10796                            mesh,
10797                            *transform,
10798                            paint,
10799                            *blend,
10800                            &mut current_pass.cmds,
10801                        );
10802                    }
10803                }
10804                SceneNode::VectorOverlay { meshes } => {
10805                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10806                    let has_visible = meshes
10807                        .iter()
10808                        .any(|mesh| visible(mesh_aabb(mesh, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip));
10809                    if !has_visible {
10810                        continue;
10811                    }
10812                    flush_batch!();
10813                    for (index, m) in meshes.iter().enumerate() {
10814                        if !visible(mesh_aabb(m, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip) {
10815                            continue;
10816                        }
10817                        let Some(mesh_gpu) = self.upload_overlay_mesh(meshes, index) else {
10818                            continue;
10819                        };
10820                        let Some(uoff) = self.alloc_mesh_uniform(MeshUniform::identity()) else {
10821                            continue;
10822                        };
10823                        current_pass.cmds.push(Cmd::VectorOverlay {
10824                            mesh: mesh_gpu,
10825                            uoff,
10826                        });
10827                    }
10828                }
10829                SceneNode::PushVectorClip { mesh, op } => {
10830                    flush_batch!();
10831                    let difference = matches!(op, repose_core::ClipOp::Difference);
10832                    let t_identity = Transform::identity();
10833                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10834                    let affine =
10835                        combine_mesh_affine(current_transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
10836                    let aabb = mesh_aabb(mesh, affine);
10837                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10838                    let next = if difference {
10839                        top
10840                    } else {
10841                        intersect(top, aabb)
10842                    };
10843                    scissor_stack.push(next);
10844                    let scissor = to_scissor(
10845                        &next,
10846                        current_target_size.0 as u32,
10847                        current_target_size.1 as u32,
10848                    );
10849                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10850                    let mut blocked = current_pass.active_scissor.is_none() || has_inverse;
10851                    if has_inverse {
10852                        log::error!(
10853                            "unsupported clip nesting: a Difference clip cannot contain another vector clip"
10854                        );
10855                    }
10856                    let has_area = aabb.x.is_finite()
10857                        && aabb.y.is_finite()
10858                        && aabb.w.is_finite()
10859                        && aabb.h.is_finite()
10860                        && aabb.w > 0.0
10861                        && aabb.h > 0.0;
10862                    let mut applied = false;
10863                    let mut geometry = None;
10864                    let mut uoff = 0;
10865                    if !blocked
10866                        && has_area
10867                        && (if difference {
10868                            visible(aabb, top)
10869                        } else {
10870                            scissor.2 > 0 && scissor.3 > 0
10871                        })
10872                    {
10873                        if let Some(mesh_gpu) = self.upload_mesh_geometry(mesh)
10874                            && let Some(slot) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
10875                                affine,
10876                                &repose_core::PaintDesc::Solid,
10877                            ))
10878                        {
10879                            geometry = Some(mesh_gpu.clone());
10880                            uoff = slot;
10881                            applied = true;
10882                            current_pass.cmds.push(Cmd::VectorClipPush {
10883                                mesh: mesh_gpu,
10884                                uoff,
10885                                scissor,
10886                                difference,
10887                                applied,
10888                            });
10889                        } else {
10890                            blocked = !difference;
10891                        }
10892                    } else if !difference {
10893                        blocked = true;
10894                    }
10895                    current_pass.active_scissor = if blocked {
10896                        None
10897                    } else {
10898                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10899                    };
10900                    active_clips.push(ActiveClip::Vector {
10901                        mesh: geometry,
10902                        source: mesh.clone(),
10903                        uoff,
10904                        affine,
10905                        scissor,
10906                        difference,
10907                        applied,
10908                        blocked,
10909                    });
10910                }
10911                SceneNode::PopVectorClip => {
10912                    flush_batch!();
10913                    if scissor_stack.is_empty() || active_clips.is_empty() {
10914                        log::error!("PopVectorClip does not match an active clip");
10915                    } else {
10916                        scissor_stack.pop();
10917                    }
10918                    let clip = active_clips.pop();
10919                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10920                    let scissor = to_scissor(
10921                        &top,
10922                        current_target_size.0 as u32,
10923                        current_target_size.1 as u32,
10924                    );
10925                    current_pass.active_scissor =
10926                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10927                    match clip {
10928                        Some(ActiveClip::Vector {
10929                            mesh: Some(mesh),
10930                            uoff,
10931                            scissor: stencil_scissor,
10932                            difference,
10933                            applied,
10934                            ..
10935                        }) if applied && mesh.vertex_count > 0 && mesh.index_count > 0 => {
10936                            current_pass.cmds.push(Cmd::VectorClipPop {
10937                                mesh,
10938                                uoff,
10939                                scissor,
10940                                stencil_scissor,
10941                                difference,
10942                                applied: true,
10943                            });
10944                        }
10945                        Some(ActiveClip::Rect { .. }) => {
10946                            log::error!("PopVectorClip matched a rounded-rect clip");
10947                        }
10948                        None => {}
10949                        _ => {}
10950                    }
10951                }
10952                SceneNode::Callback { rect, payload } => {
10953                    let t = transform_stack
10954                        .last()
10955                        .copied()
10956                        .unwrap_or(Transform::identity());
10957                    let transformed = affine_aabb(&t, rect);
10958                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10959                    let clip_rect = intersect(transformed, top);
10960                    let scissor = to_scissor(
10961                        &clip_rect,
10962                        current_target_size.0 as u32,
10963                        current_target_size.1 as u32,
10964                    );
10965                    let restore_scissor = to_scissor(
10966                        &top,
10967                        current_target_size.0 as u32,
10968                        current_target_size.1 as u32,
10969                    );
10970                    if current_pass.active_scissor.is_none()
10971                        || !visible(transformed, top)
10972                        || scissor.2 == 0
10973                        || scissor.3 == 0
10974                    {
10975                        continue;
10976                    }
10977                    flush_batch!();
10978                    current_pass.cmds.push(Cmd::Callback {
10979                        rect: transformed,
10980                        clip_rect,
10981                        scissor,
10982                        restore_scissor,
10983                        callback_id: Arc::as_ptr(payload) as *const () as usize,
10984                        payload: payload.clone(),
10985                    });
10986                }
10987                _ => {}
10988            }
10989        }
10990
10991        flush_pending_layer!();
10992        flush_batch!();
10993        passes.push(current_pass);
10994
10995        {
10996            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
10997            let mut prepare_list: Vec<(usize, Arc<Callback>)> = Vec::new();
10998            for pass in &passes {
10999                for command in &pass.cmds {
11000                    if let Cmd::Callback { payload, .. } = command
11001                        && payload.downcast_ref::<Callback>().is_some()
11002                    {
11003                        let ptr = Arc::as_ptr(payload) as *const () as usize;
11004                        if seen.insert(ptr)
11005                            && let Ok(cb_arc) = payload.clone().downcast::<Callback>()
11006                        {
11007                            prepare_list.push((ptr, cb_arc));
11008                        }
11009                    }
11010                }
11011            }
11012            if !prepare_list.is_empty() {
11013                let mut callback_targets: HashMap<usize, Vec<(PassTarget, ScreenDescriptor)>> =
11014                    HashMap::new();
11015                for pass in &passes {
11016                    let (size, target_format, sample_count) = match pass.target {
11017                        PassTarget::Surface => (
11018                            [self.output_width, self.output_height],
11019                            if self.working_space {
11020                                wgpu::TextureFormat::Rgba16Float
11021                            } else {
11022                                self.output_format
11023                            },
11024                            self.active_surface_msaa_samples(),
11025                        ),
11026                        PassTarget::Layer(layer_id) => {
11027                            let layer = self.layer_pool.get(&layer_id);
11028                            (
11029                                layer.map_or([self.output_width, self.output_height], |layer| {
11030                                    [layer.width, layer.height]
11031                                }),
11032                                self.layer_target_format(),
11033                                1,
11034                            )
11035                        }
11036                    };
11037                    for command in &pass.cmds {
11038                        if let Cmd::Callback { payload, .. } = command {
11039                            let descriptor = ScreenDescriptor {
11040                                size_in_pixels: size,
11041                                pixels_per_point: self.pixels_per_point,
11042                                target_format,
11043                                sample_count,
11044                            };
11045                            let targets = callback_targets
11046                                .entry(Arc::as_ptr(payload) as *const () as usize)
11047                                .or_default();
11048                            if !targets.iter().any(|(target, target_desc)| {
11049                                *target == pass.target
11050                                    && target_desc.target_format == descriptor.target_format
11051                                    && target_desc.sample_count == descriptor.sample_count
11052                            }) {
11053                                targets.push((pass.target, descriptor));
11054                            }
11055                        }
11056                    }
11057                }
11058
11059                let default_descriptor = ScreenDescriptor {
11060                    size_in_pixels: [self.output_width, self.output_height],
11061                    pixels_per_point: self.pixels_per_point,
11062                    target_format: if self.working_space {
11063                        wgpu::TextureFormat::Rgba16Float
11064                    } else {
11065                        self.output_format
11066                    },
11067                    sample_count: self.active_surface_msaa_samples(),
11068                };
11069                let default_descriptors = [(PassTarget::Surface, default_descriptor)];
11070                let mut prepare_encoder =
11071                    self.device
11072                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11073                            label: Some("callback prepare"),
11074                        });
11075                let mut finish_encoder =
11076                    self.device
11077                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11078                            label: Some("callback finish prepare"),
11079                        });
11080                let mut active_callback_scopes = HashSet::new();
11081                for (key, cb) in &prepare_list {
11082                    let descriptors = callback_targets
11083                        .get(key)
11084                        .map(Vec::as_slice)
11085                        .unwrap_or(&default_descriptors);
11086                    for &(target, screen_desc) in descriptors {
11087                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11088                        active_callback_scopes.insert(scope.clone());
11089                        if !scope.callback.reusable
11090                            && self
11091                                .callback_scope_payloads
11092                                .get(&scope)
11093                                .is_some_and(|payload| payload.upgrade().is_none())
11094                        {
11095                            self.remove_callback_scope(&scope);
11096                        }
11097                        let payload: Weak<Callback> = Arc::downgrade(cb);
11098                        self.callback_scope_payloads.insert(scope, payload);
11099                    }
11100                }
11101                let mut prepare_buffers = Vec::new();
11102                let mut finish_buffers = Vec::new();
11103                for (key, cb) in &prepare_list {
11104                    let descriptors = callback_targets
11105                        .get(key)
11106                        .map(Vec::as_slice)
11107                        .unwrap_or(&default_descriptors);
11108                    for &(target, screen_desc) in descriptors {
11109                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11110                        self.touch_callback_scope(scope.clone());
11111                        if !self.callback_scoped_resources.contains_key(&scope)
11112                            && self.callback_scoped_resources.len() >= MAX_CALLBACK_SCOPES
11113                        {
11114                            self.evict_callback_scope(&active_callback_scopes);
11115                        }
11116                        let resources = self.callback_scoped_resources.entry(scope).or_default();
11117                        prepare_buffers.extend(cb.0.prepare(
11118                            &self.device,
11119                            &self.queue,
11120                            &mut prepare_encoder,
11121                            &screen_desc,
11122                            resources,
11123                        ));
11124                    }
11125                }
11126                for (key, cb) in &prepare_list {
11127                    let descriptors = callback_targets
11128                        .get(key)
11129                        .map(Vec::as_slice)
11130                        .unwrap_or(&default_descriptors);
11131                    for &(target, screen_desc) in descriptors {
11132                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11133                        self.touch_callback_scope(scope.clone());
11134                        if let Some(resources) = self.callback_scoped_resources.get_mut(&scope) {
11135                            finish_buffers.extend(cb.0.finish_prepare(
11136                                &self.device,
11137                                &self.queue,
11138                                &mut finish_encoder,
11139                                &screen_desc,
11140                                resources,
11141                            ));
11142                        }
11143                    }
11144                }
11145                let mut callback_buffers =
11146                    Vec::with_capacity(2 + prepare_buffers.len() + finish_buffers.len());
11147                callback_buffers.push(prepare_encoder.finish());
11148                callback_buffers.extend(prepare_buffers);
11149                callback_buffers.push(finish_encoder.finish());
11150                callback_buffers.extend(finish_buffers);
11151                self.queue.submit(callback_buffers);
11152            }
11153        }
11154
11155        let globals_bytes = std::mem::size_of::<Globals>() as u64;
11156        if !self.ensure_globals_staging(passes.len()) {
11157            return;
11158        }
11159        for (i, pass) in passes.iter().enumerate() {
11160            let (target_w, target_h) = match pass.target {
11161                PassTarget::Surface => (fb_w, fb_h),
11162                PassTarget::Layer(layer_id) => {
11163                    let lt = self.layer_pool.get(&layer_id);
11164                    (
11165                        lt.map_or(fb_w, |l| l.width as f32),
11166                        lt.map_or(fb_h, |l| l.height as f32),
11167                    )
11168                }
11169            };
11170            self.queue.write_buffer(
11171                &self.globals_staging,
11172                (i as u64) * globals_bytes,
11173                bytemuck::bytes_of(&make_globals(target_w, target_h)),
11174            );
11175        }
11176
11177        let bind_mask = self.atlas_bind_group_mask();
11178        let bind_color = self.atlas_bind_group_color();
11179        let mut clip_depth: u32 = 0;
11180        let mut clip_depth_stack: Vec<u32> = Vec::new();
11181
11182        let snapshot_source = target_texture.cloned();
11183        let last_surface_pass = passes
11184            .iter()
11185            .rposition(|pass| matches!(pass.target, PassTarget::Surface));
11186        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
11187            // Populate backdrop snapshots for blends composited in this
11188            // pass: copy the parent target region into the snapshot
11189            // texture. Passes execute in order, so the parent holds the
11190            // true backdrop. Surface parents need the target texture (no
11191            // swapchain sampling mid-frame); layer parents copy from the
11192            // pool. Either way the region is parent-target pixels. Copies
11193            // for other passes stay queued (`remaining`).
11194            let needs_snapshot = pass
11195                .cmds
11196                .iter()
11197                .any(|c| matches!(c, Cmd::BlendLayer { .. }));
11198            if needs_snapshot {
11199                let copies = std::mem::take(&mut self.blend_copies);
11200                let has_surface_copy = copies.iter().any(|copy| copy.target == PassTarget::Surface);
11201                let mut resolved_surface = None;
11202                if has_surface_copy
11203                    && !self.working_space
11204                    && let (Some(msaa_view), Some(resolve_view)) =
11205                        (self.msaa_view.as_ref(), self.surface_resolve_view.as_ref())
11206                {
11207                    let resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11208                        label: Some("surface blend resolve"),
11209                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11210                            view: msaa_view,
11211                            resolve_target: Some(resolve_view),
11212                            ops: wgpu::Operations {
11213                                load: wgpu::LoadOp::Load,
11214                                store: wgpu::StoreOp::Store,
11215                            },
11216                            depth_slice: None,
11217                        })],
11218                        depth_stencil_attachment: None,
11219                        timestamp_writes: None,
11220                        occlusion_query_set: None,
11221                        multiview_mask: None,
11222                    });
11223                    drop(resolve_pass);
11224                    resolved_surface = self.surface_resolve_tex.clone();
11225                }
11226                let mut remaining = Vec::with_capacity(copies.len());
11227                for copy in copies {
11228                    if copy.pass_id != pass.id {
11229                        remaining.push(copy);
11230                        continue;
11231                    }
11232                    let BlendCopy {
11233                        blend_id,
11234                        target,
11235                        region,
11236                        ..
11237                    } = copy;
11238                    let (src_tex, tw, th) = match target {
11239                        PassTarget::Layer(parent_id) => match self.layer_pool.get(&parent_id) {
11240                            Some(lt) => (lt.texture.clone(), lt.width, lt.height),
11241                            None => continue,
11242                        },
11243                        PassTarget::Surface => {
11244                            let texture = if self.working_space {
11245                                self.ws_tex.clone()
11246                            } else if self.msaa_view.is_some() {
11247                                resolved_surface.clone()
11248                            } else {
11249                                snapshot_source.clone()
11250                            };
11251                            match texture {
11252                                Some(texture) => (texture, self.output_width, self.output_height),
11253                                None => {
11254                                    self.remove_blend_snapshot(blend_id);
11255                                    continue;
11256                                }
11257                            }
11258                        }
11259                    };
11260                    let Some(dst_tex) = self.blend_snapshot_texture(blend_id) else {
11261                        continue;
11262                    };
11263                    if !src_tex.usage().contains(wgpu::TextureUsages::COPY_SRC)
11264                        || !dst_tex.usage().contains(wgpu::TextureUsages::COPY_DST)
11265                    {
11266                        self.remove_blend_snapshot(blend_id);
11267                        continue;
11268                    }
11269                    let Some((sx, sy, cw, ch)) = checked_copy_region(region, tw, th) else {
11270                        self.remove_blend_snapshot(blend_id);
11271                        continue;
11272                    };
11273                    encoder.copy_texture_to_texture(
11274                        wgpu::TexelCopyTextureInfo {
11275                            texture: &src_tex,
11276                            mip_level: 0,
11277                            origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
11278                            aspect: wgpu::TextureAspect::All,
11279                        },
11280                        wgpu::TexelCopyTextureInfo {
11281                            texture: &dst_tex,
11282                            mip_level: 0,
11283                            origin: wgpu::Origin3d::ZERO,
11284                            aspect: wgpu::TextureAspect::All,
11285                        },
11286                        wgpu::Extent3d {
11287                            width: cw,
11288                            height: ch,
11289                            depth_or_array_layers: 1,
11290                        },
11291                    );
11292                }
11293                self.blend_copies = remaining;
11294            }
11295            let (color_view, resolve_target, depth_stencil_view, is_layer, working_target) =
11296                match pass.target {
11297                    PassTarget::Surface => {
11298                        let swap_view = target_view.clone();
11299                        let use_ws = self.working_space && self.ws_view.is_some();
11300                        let (color, resolve) = if use_ws {
11301                            let ws_view = self.ws_view.as_ref().unwrap();
11302                            if let Some(msaa_view) = &self.ws_msaa_view {
11303                                (msaa_view.clone(), Some(ws_view.clone()))
11304                            } else {
11305                                (ws_view.clone(), None)
11306                            }
11307                        } else if let Some(msaa_view) = &self.msaa_view {
11308                            // Resolve once per frame. Graphics layers split the
11309                            // surface into several passes that all accumulate into
11310                            // the same multisampled attachment, so only the last
11311                            // one can contribute to the presented image.
11312                            let resolve =
11313                                (Some(pass_index) == last_surface_pass).then(|| swap_view.clone());
11314                            (msaa_view.clone(), resolve)
11315                        } else {
11316                            (swap_view, None)
11317                        };
11318                        (
11319                            color,
11320                            resolve,
11321                            self.depth_stencil_view.clone(),
11322                            false,
11323                            use_ws,
11324                        )
11325                    }
11326                    PassTarget::Layer(layer_id) => {
11327                        if let Some(lt) = self.layer_pool.get(&layer_id) {
11328                            (
11329                                lt.view.clone(),
11330                                None,
11331                                lt.depth_stencil_view.clone(),
11332                                true,
11333                                false,
11334                            )
11335                        } else {
11336                            log::warn!("missing layer target {layer_id}");
11337                            continue;
11338                        }
11339                    }
11340                };
11341
11342            encoder.copy_buffer_to_buffer(
11343                &self.globals_staging,
11344                (pass_index as u64) * globals_bytes,
11345                &self.globals_buf,
11346                0,
11347                globals_bytes,
11348            );
11349
11350            if is_layer {
11351                clip_depth_stack.push(clip_depth);
11352                clip_depth = 0;
11353            }
11354
11355            let (tw, th) = match pass.target {
11356                PassTarget::Surface => (self.output_width, self.output_height),
11357                PassTarget::Layer(layer_id) => self
11358                    .layer_pool
11359                    .get(&layer_id)
11360                    .map(|l| (l.width, l.height))
11361                    .unwrap_or((self.output_width, self.output_height)),
11362            };
11363            let initial_scissor = clamp_scissor(
11364                pass.initial_scissor.0,
11365                pass.initial_scissor.1,
11366                pass.initial_scissor.2,
11367                pass.initial_scissor.3,
11368                tw,
11369                th,
11370            );
11371            let mut active_scissor = pass
11372                .active_scissor
11373                .map(|scissor| clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th));
11374            if active_scissor.is_some_and(|scissor| scissor.2 == 0 || scissor.3 == 0) {
11375                active_scissor = None;
11376            }
11377
11378            let pipes: &Pipelines = if is_layer {
11379                if self.working_space {
11380                    &self.working_space_layer_pipes
11381                } else {
11382                    &self.layer_pipes
11383                }
11384            } else if working_target {
11385                &self.working_space_pipes
11386            } else {
11387                &self.surface_pipes
11388            };
11389
11390            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11391                label: Some("pass"),
11392                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11393                    view: &color_view,
11394                    resolve_target: resolve_target.as_ref(),
11395                    ops: wgpu::Operations {
11396                        load: match pass.clear_color {
11397                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
11398                                r: c[0] as f64,
11399                                g: c[1] as f64,
11400                                b: c[2] as f64,
11401                                a: c[3] as f64,
11402                            }),
11403                            None => wgpu::LoadOp::Load,
11404                        },
11405                        store: wgpu::StoreOp::Store,
11406                    },
11407                    depth_slice: None,
11408                })],
11409                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
11410                    view: &depth_stencil_view,
11411                    depth_ops: None,
11412                    stencil_ops: Some(wgpu::Operations {
11413                        load: if pass.clear_color.is_some() {
11414                            wgpu::LoadOp::Clear(STENCIL_BASE)
11415                        } else {
11416                            wgpu::LoadOp::Load
11417                        },
11418                        store: wgpu::StoreOp::Store,
11419                    }),
11420                }),
11421                timestamp_writes: None,
11422                occlusion_query_set: None,
11423                multiview_mask: None,
11424            });
11425
11426            rpass.set_bind_group(0, &self.globals_bind, &[]);
11427            rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11428            let initial_draw_scissor = if initial_scissor.2 == 0 || initial_scissor.3 == 0 {
11429                (0, 0, 1, 1)
11430            } else {
11431                initial_scissor
11432            };
11433            rpass.set_scissor_rect(
11434                initial_draw_scissor.0,
11435                initial_draw_scissor.1,
11436                initial_draw_scissor.2,
11437                initial_draw_scissor.3,
11438            );
11439
11440            macro_rules! draw_simple {
11441                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
11442                    rpass.set_pipeline($pipeline);
11443                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11444                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11445                    rpass.draw(0..6, 0..$n);
11446                }};
11447            }
11448
11449            macro_rules! draw_with_bind {
11450                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
11451                    rpass.set_pipeline($pipeline);
11452                    rpass.set_bind_group(1, $bind, &[]);
11453                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11454                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11455                    rpass.draw(0..6, 0..$n);
11456                }};
11457            }
11458
11459            macro_rules! draw_indexed_mesh {
11460                ($pipeline:expr, $uoff:ident, $mesh:ident) => {{
11461                    rpass.set_pipeline($pipeline);
11462                    rpass.set_bind_group(1, &self.mesh_bind, &[$uoff as u32]);
11463                    let vbytes =
11464                        ($mesh.vertex_count as u64) * std::mem::size_of::<MeshVertex>() as u64;
11465                    rpass.set_vertex_buffer(0, $mesh.vertices.slice(0..vbytes));
11466                    let ibytes = ($mesh.index_count as u64) * std::mem::size_of::<u32>() as u64;
11467                    rpass.set_index_buffer(
11468                        $mesh.indices.slice(0..ibytes),
11469                        wgpu::IndexFormat::Uint32,
11470                    );
11471                    rpass.draw_indexed(0..$mesh.index_count, 0, 0..1);
11472                }};
11473            }
11474
11475            for cmd in pass.cmds {
11476                if active_scissor.is_none()
11477                    && !matches!(
11478                        &cmd,
11479                        Cmd::ClipPush { .. }
11480                            | Cmd::ClipPop { .. }
11481                            | Cmd::VectorClipPush { .. }
11482                            | Cmd::VectorClipPop { .. }
11483                    )
11484                {
11485                    continue;
11486                }
11487                match cmd {
11488                    Cmd::ClipPush {
11489                        off,
11490                        cnt: n,
11491                        scissor,
11492                        difference,
11493                        applied,
11494                    } => {
11495                        let scissor =
11496                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11497                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11498                        if usable {
11499                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11500                            active_scissor = Some(scissor);
11501                            rpass.set_pipeline(if difference {
11502                                &pipes.clip_dec
11503                            } else {
11504                                &pipes.clip_bin
11505                            });
11506
11507                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11508                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11509                            rpass.draw(0..6, 0..n);
11510                            if !difference {
11511                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11512                            }
11513                        } else {
11514                            active_scissor = None;
11515                        }
11516                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11517                    }
11518
11519                    Cmd::ClipPop {
11520                        off,
11521                        cnt: n,
11522                        scissor,
11523                        stencil_scissor,
11524                        difference,
11525                        applied,
11526                    } => {
11527                        let scissor =
11528                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11529                        let dec = clamp_scissor(
11530                            stencil_scissor.0,
11531                            stencil_scissor.1,
11532                            stencil_scissor.2,
11533                            stencil_scissor.3,
11534                            tw,
11535                            th,
11536                        );
11537                        if scissor.2 > 0 && scissor.3 > 0 {
11538                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11539                            active_scissor = Some(scissor);
11540                        } else {
11541                            active_scissor = None;
11542                        }
11543                        if applied && n > 0 && dec.2 > 0 && dec.3 > 0 {
11544                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11545                            rpass.set_pipeline(if difference {
11546                                &pipes.clip_bin
11547                            } else {
11548                                &pipes.clip_dec
11549                            });
11550                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11551                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11552                            rpass.draw(0..6, 0..n);
11553                            if scissor.2 > 0 && scissor.3 > 0 {
11554                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11555                            }
11556                            if !difference {
11557                                clip_depth = clip_depth.saturating_sub(1);
11558                            }
11559                        }
11560                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11561                    }
11562
11563                    Cmd::Rect { off, cnt: n } => {
11564                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
11565                    }
11566
11567                    Cmd::Border { off, cnt: n } => {
11568                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
11569                    }
11570
11571                    Cmd::GlyphsMask { off, cnt: n } => {
11572                        draw_with_bind!(
11573                            &pipes.text_mask,
11574                            self.glyph_mask.ring,
11575                            GlyphInstance,
11576                            &bind_mask,
11577                            off,
11578                            n
11579                        );
11580                    }
11581
11582                    Cmd::GlyphsColor { off, cnt: n } => {
11583                        draw_with_bind!(
11584                            &pipes.text_color,
11585                            self.glyph_color.ring,
11586                            GlyphInstance,
11587                            &bind_color,
11588                            off,
11589                            n
11590                        );
11591                    }
11592
11593                    Cmd::GlyphsVector { off, cnt: n } => {
11594                        if let Some(slug_pipe) = pipes.slug.as_ref() {
11595                            rpass.set_pipeline(slug_pipe);
11596                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
11597                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
11598                            rpass.draw(0..n, 0..1);
11599                        }
11600                    }
11601
11602                    Cmd::ImageRgba {
11603                        off,
11604                        cnt: n,
11605                        handle,
11606                        filter,
11607                        tile,
11608                        blend,
11609                    } => {
11610                        let bind_opt = match self.images.get(&handle) {
11611                            Some(ImageTex::Rgba { binds, .. }) => Some(binds.get(filter, tile)),
11612                            Some(ImageTex::User { binds, .. }) => Some(binds.get(filter, tile)),
11613                            _ => None,
11614                        };
11615                        if let Some(bind) = bind_opt {
11616                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11617                            {
11618                                Some(index) => &pipes.image_blends[index],
11619                                None => &pipes.image_rgba,
11620                            };
11621                            draw_with_bind!(
11622                                pipe,
11623                                self.glyph_color.ring,
11624                                GlyphInstance,
11625                                bind,
11626                                off,
11627                                n
11628                            );
11629                        }
11630                    }
11631                    Cmd::Coverage {
11632                        off,
11633                        cnt: n,
11634                        handle,
11635                    } => {
11636                        if let Some(tile) = self.coverages.get(&handle) {
11637                            draw_with_bind!(
11638                                &pipes.coverage,
11639                                self.glyph_color.ring,
11640                                GlyphInstance,
11641                                &tile.bind,
11642                                off,
11643                                n
11644                            );
11645                        }
11646                    }
11647
11648                    Cmd::ImageNv12 {
11649                        off,
11650                        cnt: n,
11651                        handle,
11652                        filter,
11653                        blend,
11654                    } => {
11655                        if let Some(ImageTex::Nv12 { binds, .. }) = self.images.get(&handle) {
11656                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11657                            {
11658                                Some(index) => &pipes.image_nv12_blends[index],
11659                                None => &pipes.image_nv12,
11660                            };
11661                            draw_with_bind!(
11662                                pipe,
11663                                self.nv12.ring,
11664                                Nv12Instance,
11665                                binds.get(filter, false),
11666                                off,
11667                                n
11668                            );
11669                        }
11670                    }
11671
11672                    Cmd::Ellipse { off, cnt: n } => {
11673                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
11674                    }
11675
11676                    Cmd::EllipseBorder { off, cnt: n } => {
11677                        draw_simple!(
11678                            &pipes.ellipse_borders,
11679                            self.ellipse_borders.ring,
11680                            EllipseBorderInstance,
11681                            off,
11682                            n
11683                        );
11684                    }
11685
11686                    Cmd::Arc { off, cnt: n } => {
11687                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
11688                    }
11689
11690                    Cmd::CompositeLayer {
11691                        off,
11692                        cnt: n,
11693                        layer_id,
11694                    } => {
11695                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11696                            draw_with_bind!(
11697                                &pipes.image_rgba,
11698                                self.glyph_color.ring,
11699                                GlyphInstance,
11700                                &lt.bind,
11701                                off,
11702                                n
11703                            );
11704                        }
11705                    }
11706                    Cmd::CompositeShadow {
11707                        off,
11708                        cnt: n,
11709                        layer_id,
11710                    } => {
11711                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11712                            draw_with_bind!(
11713                                &pipes.blur,
11714                                self.blur_ring,
11715                                BlurInstance,
11716                                &lt.bind_linear,
11717                                off,
11718                                n
11719                            );
11720                        }
11721                    }
11722                    Cmd::CompositeBlur {
11723                        off,
11724                        cnt: n,
11725                        layer_id,
11726                    } => {
11727                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11728                            draw_with_bind!(
11729                                &pipes.blur_content,
11730                                self.blur_ring,
11731                                BlurInstance,
11732                                &lt.bind_linear,
11733                                off,
11734                                n
11735                            );
11736                        }
11737                    }
11738                    Cmd::CompositeProjective {
11739                        off,
11740                        cnt: n,
11741                        layer_id,
11742                    } => {
11743                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11744                            // The layer texture is sampled with the rgba
11745                            // (non-linear-filter) binding, like the sharp
11746                            // composite path.
11747                            draw_with_bind!(
11748                                &pipes.projective_layer,
11749                                self.projective_ring,
11750                                ProjectiveInstance,
11751                                &lt.bind,
11752                                off,
11753                                n
11754                            );
11755                        }
11756                    }
11757
11758                    Cmd::BlendLayer {
11759                        off,
11760                        cnt: n,
11761                        src_layer,
11762                        dst_layer,
11763                        parent,
11764                        scissor,
11765                    } => {
11766                        let src = self.layer_pool.get(&src_layer).cloned();
11767                        // Backdrop is the snapshot copied from the parent
11768                        // target before this pass opened (a texture cannot be
11769                        // sampled mid-pass while bound as a render target).
11770                        // A missing snapshot (surface parent without texture,
11771                        // evicted layer) skips the draw: the mesh vanishes
11772                        // rather than misrendering.
11773                        let dst = dst_layer.and_then(|id| self.blend_snapshots.get(&id).cloned());
11774                        let in_parent = match parent {
11775                            PassTarget::Surface => !is_layer,
11776                            PassTarget::Layer(id) => {
11777                                matches!(pass.target, PassTarget::Layer(pid) if pid == id)
11778                            }
11779                        };
11780                        if let (Some(src_lt), Some(dst_snap)) = (src, dst)
11781                            && in_parent
11782                        {
11783                            let bytes = (n as u64) * std::mem::size_of::<BlendInstance>() as u64;
11784                            rpass.set_pipeline(&pipes.blend_layer);
11785                            let scissor =
11786                                clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11787                            if scissor.2 > 0 && scissor.3 > 0 {
11788                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11789                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11790                                rpass.set_bind_group(1, &src_lt.bind, &[]);
11791                                rpass.set_bind_group(2, &dst_snap.bind, &[]);
11792                                rpass.set_vertex_buffer(
11793                                    0,
11794                                    self.blend_ring.buf.slice(off..off + bytes),
11795                                );
11796                                rpass.draw(0..6, 0..n);
11797                            }
11798                        }
11799                    }
11800
11801                    Cmd::VectorMesh { mesh, uoff, blend } => {
11802                        let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend) {
11803                            Some(index) => &pipes.mesh_blends[index],
11804                            None => &pipes.mesh,
11805                        };
11806                        draw_indexed_mesh!(pipe, uoff, mesh);
11807                    }
11808
11809                    Cmd::VectorOverlay { mesh, uoff } => {
11810                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11811                        draw_indexed_mesh!(&pipes.mesh_overlay, uoff, mesh);
11812                    }
11813
11814                    Cmd::VectorClipPush {
11815                        mesh,
11816                        uoff,
11817                        scissor,
11818                        difference,
11819                        applied,
11820                    } => {
11821                        let scissor =
11822                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11823                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11824                        if usable {
11825                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11826                            active_scissor = Some(scissor);
11827                            let pipe = if difference {
11828                                &pipes.mesh_clip_dec
11829                            } else {
11830                                &pipes.mesh_clip_inc
11831                            };
11832                            draw_indexed_mesh!(pipe, uoff, mesh);
11833                            if !difference {
11834                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11835                            }
11836                        } else {
11837                            active_scissor = None;
11838                        }
11839                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11840                    }
11841
11842                    Cmd::VectorClipPop {
11843                        mesh,
11844                        uoff,
11845                        scissor,
11846                        stencil_scissor,
11847                        difference,
11848                        applied,
11849                    } => {
11850                        let scissor =
11851                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11852                        let dec = clamp_scissor(
11853                            stencil_scissor.0,
11854                            stencil_scissor.1,
11855                            stencil_scissor.2,
11856                            stencil_scissor.3,
11857                            tw,
11858                            th,
11859                        );
11860                        if scissor.2 > 0 && scissor.3 > 0 {
11861                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11862                            active_scissor = Some(scissor);
11863                        } else {
11864                            active_scissor = None;
11865                        }
11866                        if applied && dec.2 > 0 && dec.3 > 0 {
11867                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11868                            let pipe = if difference {
11869                                &pipes.mesh_clip_inc
11870                            } else {
11871                                &pipes.mesh_clip_dec
11872                            };
11873                            draw_indexed_mesh!(pipe, uoff, mesh);
11874                            if scissor.2 > 0 && scissor.3 > 0 {
11875                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11876                            }
11877                            if !difference {
11878                                clip_depth = clip_depth.saturating_sub(1);
11879                            }
11880                        }
11881                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11882                    }
11883
11884                    Cmd::Callback {
11885                        rect,
11886                        clip_rect,
11887                        scissor,
11888                        restore_scissor,
11889                        callback_id,
11890                        payload,
11891                    } => {
11892                        if let Some(cb) = payload.downcast_ref::<Callback>() {
11893                            // Round INWARD. A callback may only paint inside its
11894                            // own rect, and a fullscreen pass draws a single
11895                            // clip-space quad spanning twice the viewport, so an
11896                            // outward-rounded viewport overshot the rect and the
11897                            // outward-rounded scissor let that overshoot through.
11898                            let vp_x = rect.x.ceil().max(0.0);
11899                            let vp_y = rect.y.ceil().max(0.0);
11900                            let vp_w = ((rect.x + rect.w).floor() - vp_x).max(1.0);
11901                            let vp_h = ((rect.y + rect.h).floor() - vp_y).max(1.0);
11902                            if vp_w > 0.0 && vp_h > 0.0 && clip_rect.w > 0.0 && clip_rect.h > 0.0 {
11903                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11904                                rpass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
11905                                let info = repose_core::PaintCallbackInfo {
11906                                    viewport: rect,
11907                                    clip_rect,
11908                                    pixels_per_point: self.pixels_per_point,
11909                                    screen_size_px: [tw, th],
11910                                };
11911                                let descriptor = ScreenDescriptor {
11912                                    size_in_pixels: [tw, th],
11913                                    pixels_per_point: self.pixels_per_point,
11914                                    target_format: if is_layer {
11915                                        self.layer_target_format()
11916                                    } else if working_target {
11917                                        wgpu::TextureFormat::Rgba16Float
11918                                    } else {
11919                                        self.output_format
11920                                    },
11921                                    sample_count: if is_layer {
11922                                        1
11923                                    } else {
11924                                        self.active_surface_msaa_samples()
11925                                    },
11926                                };
11927                                let scope =
11928                                    callback_scope_key(cb, callback_id, pass.target, &descriptor);
11929                                let resources = self
11930                                    .callback_scoped_resources
11931                                    .get(&scope)
11932                                    .unwrap_or(&self.callback_resources);
11933                                // A panicking callback must not skip the restore below:
11934                                // the viewport/scissor would stay clamped to
11935                                // the callback rect and every later draw in the
11936                                // frame would be clipped away. Requires
11937                                // unwinding panics; under `panic = "abort"`
11938                                // nothing runs the restore.
11939                                let outcome = {
11940                                    let mut callback_pass = CallbackRenderPass::new(
11941                                        &mut rpass,
11942                                        descriptor.target_format,
11943                                        descriptor.sample_count,
11944                                    );
11945                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11946                                        cb.0.paint(info, &mut callback_pass, resources)
11947                                    }))
11948                                };
11949                                if outcome.is_err() {
11950                                    log::warn!("paint callback panicked; state restored");
11951                                }
11952                                rpass.set_viewport(0.0, 0.0, tw as f32, th as f32, 0.0, 1.0);
11953                                let restore = if restore_scissor.2 > 0 && restore_scissor.3 > 0 {
11954                                    restore_scissor
11955                                } else {
11956                                    (0, 0, 1, 1)
11957                                };
11958                                rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
11959                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11960                                rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11961                            }
11962                        } else {
11963                            log::warn!("Unknown paint callback payload");
11964                        }
11965                    }
11966                }
11967            }
11968            if is_layer {
11969                clip_depth = clip_depth_stack.pop().unwrap_or(0);
11970            }
11971        }
11972
11973        let transient_ids: HashSet<u32> = flatten_ids_used.iter().copied().collect();
11974        let producer_ids = std::mem::take(&mut self.producer_layer_ids);
11975        // A blur scratch stays bound while its source layer is live, so a stable
11976        // layer reuses its allocation instead of reallocating each frame. Once
11977        // the source is swept the scratch id is forgotten and goes with it.
11978        self.blur_scratch_ids
11979            .retain(|&(source, _), _| producer_ids.contains(&source));
11980        let live_scratch: HashSet<u32> = self.blur_scratch_ids.values().copied().collect();
11981        let stale_layers: Vec<u32> = self
11982            .layer_pool
11983            .keys()
11984            .filter_map(|id| {
11985                (!producer_ids.contains(id)
11986                    && !transient_ids.contains(id)
11987                    && !live_scratch.contains(id))
11988                .then_some(*id)
11989            })
11990            .collect();
11991        for id in stale_layers {
11992            if let Some(layer) = self.layer_pool.remove(&id) {
11993                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
11994                if layer.transient {
11995                    self.transient_layer_bytes_total =
11996                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
11997                }
11998            }
11999        }
12000        self.flatten_layer_ids = flatten_ids_used;
12001
12002        // Display pass: linear working space -> sRGB OETF -> swapchain
12003        if self.working_space
12004            && let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
12005                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
12006        {
12007            let swap_view = target_view.clone();
12008            let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
12009                label: Some("display transform"),
12010                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
12011                    view: &swap_view,
12012                    resolve_target: None,
12013                    ops: wgpu::Operations {
12014                        load: wgpu::LoadOp::Load,
12015                        store: wgpu::StoreOp::Store,
12016                    },
12017                    depth_slice: None,
12018                })],
12019                depth_stencil_attachment: None,
12020                timestamp_writes: None,
12021                occlusion_query_set: None,
12022                multiview_mask: None,
12023            });
12024            display_pass.set_pipeline(display_pipeline);
12025            display_pass.set_bind_group(1, ws_bind, &[]);
12026            display_pass.draw(0..3, 0..1);
12027        }
12028        self.recycle_blend_snapshots();
12029    }
12030
12031    /// Render a scene into an externally-provided texture view.
12032    /// Use this when embedding Repose in a host that owns the GPU.
12033    /// The host is responsible for submitting the encoder and handling present.
12034    pub fn render_to_view(
12035        &mut self,
12036        scene: &Scene,
12037        encoder: &mut wgpu::CommandEncoder,
12038        target_view: &wgpu::TextureView,
12039        width: u32,
12040        height: u32,
12041        clear_color: Option<[f64; 4]>,
12042    ) {
12043        self.resize(width, height);
12044
12045        if let Err(error) = self.begin_frame() {
12046            log::error!("render_to_view: {error:#}");
12047            return;
12048        }
12049
12050        if self.output_width == 0 || self.output_height == 0 {
12051            self.end_frame();
12052            return;
12053        }
12054
12055        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
12056        self.end_frame();
12057    }
12058}
12059
12060#[cfg(target_os = "linux")]
12061fn validate_dmabuf_plane(
12062    fd: &std::os::unix::io::OwnedFd,
12063    offset: u64,
12064    stride: u32,
12065    width: u32,
12066    height: u32,
12067    bytes_per_pixel: u32,
12068) -> anyhow::Result<u64> {
12069    if width == 0 || height == 0 || bytes_per_pixel == 0 {
12070        anyhow::bail!("DMA-BUF plane dimensions and bytes per pixel must be non-zero");
12071    }
12072    if !offset.is_multiple_of(4) || !stride.is_multiple_of(4) {
12073        anyhow::bail!("DMA-BUF offset and stride must be 4-byte aligned");
12074    }
12075    let row_bytes = (width as u64)
12076        .checked_mul(bytes_per_pixel as u64)
12077        .ok_or_else(|| anyhow::anyhow!("DMA-BUF row size overflow"))?;
12078    if (stride as u64) < row_bytes {
12079        anyhow::bail!(
12080            "DMA-BUF stride {} is smaller than row size {row_bytes}",
12081            stride
12082        );
12083    }
12084    let span = (height as u64 - 1)
12085        .checked_mul(stride as u64)
12086        .and_then(|value| value.checked_add(row_bytes))
12087        .ok_or_else(|| anyhow::anyhow!("DMA-BUF plane size overflow"))?;
12088    let file = std::fs::File::from(
12089        fd.try_clone()
12090            .map_err(|error| anyhow::anyhow!("duplicate DMA-BUF fd: {error}"))?,
12091    );
12092    let file_size = file
12093        .metadata()
12094        .map_err(|error| anyhow::anyhow!("stat DMA-BUF fd: {error}"))?
12095        .len();
12096    if offset > file_size || span > file_size - offset {
12097        anyhow::bail!("DMA-BUF plane offset/size {offset}+{span} exceeds file size {file_size}");
12098    }
12099    Ok(file_size)
12100}
12101
12102fn is_filterable_color_format(format: wgpu::TextureFormat) -> bool {
12103    matches!(
12104        format,
12105        wgpu::TextureFormat::Rgba8Unorm
12106            | wgpu::TextureFormat::Rgba8UnormSrgb
12107            | wgpu::TextureFormat::Bgra8Unorm
12108            | wgpu::TextureFormat::Bgra8UnormSrgb
12109            | wgpu::TextureFormat::Rgba16Unorm
12110            | wgpu::TextureFormat::Rgba16Float
12111            | wgpu::TextureFormat::Rgba32Float
12112            | wgpu::TextureFormat::Rgb10a2Unorm
12113    )
12114}
12115
12116fn native_format_supported(format: wgpu::TextureFormat, features: wgpu::Features) -> bool {
12117    if !is_filterable_color_format(format) {
12118        return false;
12119    }
12120    match format {
12121        wgpu::TextureFormat::Rgba16Unorm => {
12122            features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
12123        }
12124        wgpu::TextureFormat::Rgba32Float => features.contains(wgpu::Features::FLOAT32_FILTERABLE),
12125        _ => true,
12126    }
12127}
12128
12129fn validate_sampler_descriptor(
12130    device: &wgpu::Device,
12131    sampler: &wgpu::SamplerDescriptor<'_>,
12132) -> anyhow::Result<()> {
12133    if sampler.compare.is_some()
12134        || !matches!(
12135            sampler.mag_filter,
12136            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12137        )
12138        || !matches!(
12139            sampler.min_filter,
12140            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12141        )
12142        || sampler.anisotropy_clamp == 0
12143        || !sampler.lod_min_clamp.is_finite()
12144        || !sampler.lod_max_clamp.is_finite()
12145        || sampler.lod_min_clamp < 0.0
12146        || sampler.lod_max_clamp < sampler.lod_min_clamp
12147    {
12148        anyhow::bail!("native sampler must be non-comparison linear filtering with finite LODs");
12149    }
12150    if (matches!(sampler.address_mode_u, wgpu::AddressMode::ClampToBorder)
12151        || matches!(sampler.address_mode_v, wgpu::AddressMode::ClampToBorder)
12152        || matches!(sampler.address_mode_w, wgpu::AddressMode::ClampToBorder))
12153        && !device
12154            .features()
12155            .contains(wgpu::Features::ADDRESS_MODE_CLAMP_TO_BORDER)
12156    {
12157        anyhow::bail!("native sampler uses unsupported clamp-to-border addressing");
12158    }
12159    Ok(())
12160}
12161
12162fn validate_texture_dimensions(
12163    device: &wgpu::Device,
12164    width: u32,
12165    height: u32,
12166) -> anyhow::Result<()> {
12167    if width == 0
12168        || height == 0
12169        || width > device.limits().max_texture_dimension_2d
12170        || height > device.limits().max_texture_dimension_2d
12171    {
12172        anyhow::bail!("texture dimensions are outside the device limit");
12173    }
12174    Ok(())
12175}
12176
12177fn validate_image_handle(handle: u64) -> anyhow::Result<()> {
12178    if handle == 0 {
12179        anyhow::bail!("image handle 0 is reserved");
12180    }
12181    Ok(())
12182}
12183
12184/// Decoder limits for encoded uploads. `image::Limits::default()` only caps
12185/// allocations at 512 MiB, which a few hundred KB of hostile PNG can reach;
12186/// the dimension cap plus a decode-sized allocation cap keep the transient
12187/// inside the same envelope as the retained image budget.
12188fn decode_limits(max_dimension: u32) -> image::Limits {
12189    let mut limits = image::Limits::default();
12190    limits.max_image_width = Some(max_dimension);
12191    limits.max_image_height = Some(max_dimension);
12192    limits.max_alloc = Some(MAX_IMAGE_DECODE_BYTES);
12193    limits
12194}
12195
12196/// Decode an encoded image to RGBA8, rejecting oversized input *before* the
12197/// decoder allocates. The dimension probe runs against a separate reader
12198/// because `into_dimensions` consumes it.
12199fn decode_image_rgba8(data: &[u8], max_dimension: u32) -> anyhow::Result<(u32, u32, Vec<u8>)> {
12200    let format = image::guess_format(data)?;
12201    let (w, h) = {
12202        let mut probe = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12203        probe.limits(decode_limits(max_dimension));
12204        probe.into_dimensions()?
12205    };
12206    validate_image_decode_budget(w, h, max_dimension)?;
12207    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12208    reader.limits(decode_limits(max_dimension));
12209    let rgba = reader.decode()?.into_rgba8();
12210    Ok((w, h, rgba.into_raw()))
12211}
12212
12213fn validate_image_decode_budget(w: u32, h: u32, max_dimension: u32) -> anyhow::Result<()> {
12214    if w == 0 || h == 0 {
12215        anyhow::bail!("encoded image has zero dimensions");
12216    }
12217    if w > max_dimension || h > max_dimension {
12218        anyhow::bail!("encoded image {w}x{h} exceeds the device texture limit {max_dimension}");
12219    }
12220    let bytes = checked_image_bytes(w, h, 4)?;
12221    if bytes > MAX_IMAGE_DECODE_BYTES {
12222        anyhow::bail!(
12223            "encoded image {w}x{h} needs {bytes} bytes to decode, over the {MAX_IMAGE_DECODE_BYTES} byte limit"
12224        );
12225    }
12226    Ok(())
12227}
12228
12229fn checked_image_bytes(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<u64> {
12230    if w == 0 || h == 0 || bytes_per_pixel == 0 {
12231        anyhow::bail!("image dimensions and bytes per pixel must be non-zero");
12232    }
12233    (w as u64)
12234        .checked_mul(h as u64)
12235        .and_then(|size| size.checked_mul(bytes_per_pixel as u64))
12236        .ok_or_else(|| anyhow::anyhow!("image byte size overflow"))
12237}
12238
12239fn texture_storage_bytes(
12240    format: wgpu::TextureFormat,
12241    width: u32,
12242    height: u32,
12243) -> anyhow::Result<u64> {
12244    if width == 0 || height == 0 {
12245        anyhow::bail!("texture dimensions must be non-zero");
12246    }
12247    let bytes = format.theoretical_memory_footprint(wgpu::Extent3d {
12248        width,
12249        height,
12250        depth_or_array_layers: 1,
12251    });
12252    if bytes == 0 {
12253        anyhow::bail!("texture format has no storage size");
12254    }
12255    Ok(bytes)
12256}
12257
12258fn texture_storage_bytes_with_samples(
12259    format: wgpu::TextureFormat,
12260    width: u32,
12261    height: u32,
12262    sample_count: u32,
12263) -> anyhow::Result<u64> {
12264    texture_storage_bytes(format, width, height)?
12265        .checked_mul(u64::from(sample_count.max(1)))
12266        .ok_or_else(|| anyhow::anyhow!("multisample texture size overflow"))
12267}
12268
12269fn checked_image_bytes_usize(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<usize> {
12270    usize::try_from(checked_image_bytes(w, h, bytes_per_pixel)?)
12271        .map_err(|_| anyhow::anyhow!("image byte size exceeds addressable memory"))
12272}
12273
12274fn clamp_scissor(x: u32, y: u32, w: u32, h: u32, tw: u32, th: u32) -> (u32, u32, u32, u32) {
12275    if w == 0 || h == 0 || tw == 0 || th == 0 {
12276        return (0, 0, 0, 0);
12277    }
12278    let x = x.min(tw - 1);
12279    let y = y.min(th - 1);
12280    let w = w.min(tw - x);
12281    let h = h.min(th - y);
12282    (x, y, w, h)
12283}
12284
12285fn rect_to_scissor(r: repose_core::Rect, width: u32, height: u32) -> (u32, u32, u32, u32) {
12286    if width == 0
12287        || height == 0
12288        || !r.x.is_finite()
12289        || !r.y.is_finite()
12290        || !r.w.is_finite()
12291        || !r.h.is_finite()
12292        || r.w <= 0.0
12293        || r.h <= 0.0
12294    {
12295        return (0, 0, 0, 0);
12296    }
12297    // Inward, for the same reason as `to_scissor`: a clip covers only whole
12298    // pixels inside its rect.
12299    let x0 = r.x.ceil().max(0.0).min(width as f32);
12300    let y0 = r.y.ceil().max(0.0).min(height as f32);
12301    let x1 = (r.x + r.w).floor().max(x0).min(width as f32);
12302    let y1 = (r.y + r.h).floor().max(y0).min(height as f32);
12303    if x1 <= x0 || y1 <= y0 {
12304        return (0, 0, 0, 0);
12305    }
12306    (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
12307}
12308
12309fn rect_to_ndc(r: repose_core::Rect, width: f32, height: f32) -> [f32; 4] {
12310    if width <= 0.0 || height <= 0.0 || !r.x.is_finite() || !r.y.is_finite() {
12311        return [0.0, 0.0, 0.0, 0.0];
12312    }
12313    let x0 = r.x / width * 2.0 - 1.0;
12314    let y0 = 1.0 - r.y / height * 2.0;
12315    let x1 = (r.x + r.w) / width * 2.0 - 1.0;
12316    let y1 = 1.0 - (r.y + r.h) / height * 2.0;
12317    [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]
12318}
12319
12320fn parent_rect_to_layer_rect(
12321    rect: repose_core::Rect,
12322    parent_transform: &Transform,
12323    origin: (f32, f32),
12324) -> Option<repose_core::Rect> {
12325    let inverse = parent_transform.inverse_linear()?;
12326    let tx = parent_transform.translate_x;
12327    let ty = parent_transform.translate_y;
12328    let corners = [
12329        (rect.x, rect.y),
12330        (rect.x + rect.w, rect.y),
12331        (rect.x, rect.y + rect.h),
12332        (rect.x + rect.w, rect.y + rect.h),
12333    ];
12334    let mut min_x = f32::INFINITY;
12335    let mut min_y = f32::INFINITY;
12336    let mut max_x = f32::NEG_INFINITY;
12337    let mut max_y = f32::NEG_INFINITY;
12338    for (x, y) in corners {
12339        let local_x = x - tx;
12340        let local_y = y - ty;
12341        let transformed_x = inverse[0] * local_x + inverse[1] * local_y;
12342        let transformed_y = inverse[2] * local_x + inverse[3] * local_y;
12343        min_x = min_x.min(transformed_x);
12344        min_y = min_y.min(transformed_y);
12345        max_x = max_x.max(transformed_x);
12346        max_y = max_y.max(transformed_y);
12347    }
12348    let result = repose_core::Rect {
12349        x: min_x - origin.0,
12350        y: min_y - origin.1,
12351        w: (max_x - min_x).max(0.0),
12352        h: (max_y - min_y).max(0.0),
12353    };
12354    (result.x.is_finite() && result.y.is_finite() && result.w.is_finite() && result.h.is_finite())
12355        .then_some(result)
12356}
12357
12358fn parent_radii_to_layer_radii(radii: [f32; 4], parent_transform: &Transform) -> Option<[f32; 4]> {
12359    let m = parent_transform.linear();
12360    let sx = (m[0] * m[0] + m[2] * m[2]).sqrt();
12361    let sy = (m[1] * m[1] + m[3] * m[3]).sqrt();
12362    (sx.is_finite() && sy.is_finite() && sx > 1e-6 && sy > 1e-6)
12363        .then(|| [radii[0] / sx, radii[1] / sy, radii[2] / sx, radii[3] / sy])
12364}
12365
12366fn parent_affine_to_layer_affine(
12367    affine: [f32; 6],
12368    parent_transform: &Transform,
12369    origin: (f32, f32),
12370) -> Option<[f32; 6]> {
12371    let inverse = parent_transform.inverse_linear()?;
12372    let tx = affine[4] - parent_transform.translate_x;
12373    let ty = affine[5] - parent_transform.translate_y;
12374    Some([
12375        inverse[0] * affine[0] + inverse[1] * affine[2],
12376        inverse[0] * affine[1] + inverse[1] * affine[3],
12377        inverse[2] * affine[0] + inverse[3] * affine[2],
12378        inverse[2] * affine[1] + inverse[3] * affine[3],
12379        inverse[0] * tx + inverse[1] * ty - origin.0,
12380        inverse[2] * tx + inverse[3] * ty - origin.1,
12381    ])
12382}
12383
12384fn checked_copy_region(
12385    region: repose_core::Rect,
12386    target_width: u32,
12387    target_height: u32,
12388) -> Option<(u32, u32, u32, u32)> {
12389    if target_width == 0
12390        || target_height == 0
12391        || !region.x.is_finite()
12392        || !region.y.is_finite()
12393        || !region.w.is_finite()
12394        || !region.h.is_finite()
12395        || region.w <= 0.0
12396        || region.h <= 0.0
12397    {
12398        return None;
12399    }
12400    let x0 = region.x.floor().max(0.0).min(target_width as f32) as u32;
12401    let y0 = region.y.floor().max(0.0).min(target_height as f32) as u32;
12402    let x1 = (region.x + region.w)
12403        .ceil()
12404        .max(x0 as f32)
12405        .min(target_width as f32) as u32;
12406    let y1 = (region.y + region.h)
12407        .ceil()
12408        .max(y0 as f32)
12409        .min(target_height as f32) as u32;
12410    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
12411}
12412
12413fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
12414    let x0 = a.x.max(b.x);
12415    let y0 = a.y.max(b.y);
12416    let x1 = (a.x + a.w).min(b.x + b.w);
12417    let y1 = (a.y + a.h).min(b.y + b.h);
12418    repose_core::Rect {
12419        x: x0,
12420        y: y0,
12421        w: (x1 - x0).max(0.0),
12422        h: (y1 - y0).max(0.0),
12423    }
12424}
12425
12426#[cfg(test)]
12427mod tests {
12428    use super::*;
12429
12430    #[test]
12431    fn image_source_rect_uses_texel_edges() {
12432        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 3, 2)), 10, 8)
12433            .expect("source should be valid");
12434        assert_eq!(source.uv_min, [0.2, 0.125]);
12435        assert_eq!(source.uv_max, [0.5, 0.375]);
12436        assert_eq!(source.width, 3.0);
12437        assert_eq!(source.height, 2.0);
12438        assert!(resolve_image_source(Some(ImageSourceRect::new(8, 0, 3, 1)), 10, 8).is_none());
12439        assert!(resolve_image_source(Some(ImageSourceRect::new(0, 0, 0, 1)), 10, 8).is_none());
12440    }
12441
12442    #[test]
12443    fn image_fit_stays_inside_selected_source() {
12444        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 4, 2)), 10, 8)
12445            .expect("source should be valid");
12446        let rect = repose_core::Rect {
12447            x: 20.0,
12448            y: 30.0,
12449            w: 8.0,
12450            h: 8.0,
12451        };
12452        let (_, uv) = image_fit_geometry(rect, ImageFit::Cover, ImageAlignment::Center, &source)
12453            .expect("cover geometry should be valid");
12454        assert!(uv[0] >= source.uv_min[0] && uv[2] <= source.uv_max[0]);
12455        assert!(uv[1] >= source.uv_min[1] && uv[3] <= source.uv_max[1]);
12456    }
12457
12458    #[test]
12459    fn ring_alignment_rejects_bad_input() {
12460        assert_eq!(align_up(0, 4).unwrap(), 0);
12461        assert_eq!(align_up(1, 4).unwrap(), 4);
12462        assert!(align_up(1, 3).is_err());
12463        assert!(align_up(u64::MAX, 4).is_err());
12464    }
12465
12466    /// Every stride must be a legal dynamic offset and a legal `align_up`
12467    /// alignment at every alignment a driver may report.
12468    #[test]
12469    fn mesh_uniform_stride_is_always_a_power_of_two() {
12470        for base in [4u64, 16, 32, 64, 128, 256, 512] {
12471            for size in [
12472                std::mem::size_of::<MeshUniform>() as u64,
12473                96,
12474                272,
12475                288,
12476                1024,
12477            ] {
12478                let stride = mesh_uniform_stride(size, base);
12479                assert!(
12480                    stride.is_power_of_two(),
12481                    "stride {stride} for size {size} base {base} is not a power of two"
12482                );
12483                assert_eq!(stride % base, 0, "stride {stride} misaligned to {base}");
12484                assert!(stride >= size, "stride {stride} below size {size}");
12485                assert!(
12486                    align_up(size, stride).is_ok(),
12487                    "capacity alignment rejects stride {stride}"
12488                );
12489            }
12490        }
12491    }
12492
12493    /// Byte layout of one `MeshUniform`, as the shader sees it.
12494    fn wgsl_type_size(module: &naga::Module, ty: naga::Handle<naga::Type>) -> u32 {
12495        match &module.types[ty].inner {
12496            naga::TypeInner::Scalar(_) | naga::TypeInner::Atomic(_) => 4,
12497            naga::TypeInner::Vector { size, .. } => 4 * *size as u32,
12498            naga::TypeInner::Array {
12499                size: naga::ArraySize::Constant(count),
12500                stride,
12501                ..
12502            } => stride * count.get(),
12503            _ => 0,
12504        }
12505    }
12506
12507    /// `shaders/mesh.wgsl` and `MeshUniform` are two halves of one contract:
12508    /// the shader reads the struct by byte offset, so any member added,
12509    /// dropped, resized or reordered on one side only shows up as scrambled
12510    /// gradient uniforms. Compare member offsets (and the total size) directly.
12511    #[test]
12512    fn mesh_uniform_matches_the_wgsl_member_layout() {
12513        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/mesh.wgsl");
12514        let module = naga::front::wgsl::parse_str(&std::fs::read_to_string(path).expect("read"))
12515            .expect("mesh.wgsl parses");
12516        let wgsl = module
12517            .types
12518            .iter()
12519            .filter(|(_, ty)| ty.name.as_deref() == Some("MeshUniform"))
12520            .find_map(|(_, ty)| match &ty.inner {
12521                naga::TypeInner::Struct { members, .. } => Some(members.as_slice()),
12522                _ => None,
12523            })
12524            .expect("mesh.wgsl declares MeshUniform");
12525        let rust = [
12526            ("m0", std::mem::offset_of!(MeshUniform, m0)),
12527            ("m1", std::mem::offset_of!(MeshUniform, m1)),
12528            ("paint", std::mem::offset_of!(MeshUniform, paint)),
12529            ("color0", std::mem::offset_of!(MeshUniform, color0)),
12530            ("color1", std::mem::offset_of!(MeshUniform, color1)),
12531            ("grad_start", std::mem::offset_of!(MeshUniform, grad_start)),
12532            ("grad_end", std::mem::offset_of!(MeshUniform, grad_end)),
12533            (
12534                "stop_colors",
12535                std::mem::offset_of!(MeshUniform, stop_colors),
12536            ),
12537            (
12538                "stop_offsets",
12539                std::mem::offset_of!(MeshUniform, stop_offsets),
12540            ),
12541        ];
12542        assert_eq!(wgsl.len(), rust.len(), "MeshUniform member count");
12543        for (member, (name, offset)) in wgsl.iter().zip(rust) {
12544            assert_eq!(member.name.as_deref(), Some(name), "member order");
12545            assert_eq!(member.offset as usize, offset, "offset of {name}");
12546        }
12547        let last = wgsl.last().expect("non-empty");
12548        let wgsl_size = last.offset as usize + wgsl_type_size(&module, last.ty) as usize;
12549        assert_eq!(wgsl_size, std::mem::size_of::<MeshUniform>());
12550        assert_eq!(wgsl_size, MESH_UNIFORM_BYTES);
12551    }
12552
12553    /// The stop table rides in the same uniform as the two-point ramps, so the
12554    /// packing invariants the shader relies on (count, clamped non-decreasing
12555    /// offsets, colours left in linear space) are checked where they are built.
12556    #[test]
12557    fn gradient_stops_pack_clamped_and_monotonic() {
12558        let stops = GradientStops::from_slice(&[
12559            repose_core::GradientStop {
12560                offset: 0.25,
12561                color: repose_core::Color::from_rgba(255, 0, 0, 255),
12562            },
12563            repose_core::GradientStop {
12564                offset: f32::NAN,
12565                color: repose_core::Color::from_rgba(0, 255, 0, 255),
12566            },
12567            repose_core::GradientStop {
12568                offset: -2.0,
12569                color: repose_core::Color::from_rgba(0, 0, 255, 255),
12570            },
12571            repose_core::GradientStop {
12572                offset: 9.0,
12573                color: repose_core::Color::from_rgba(255, 255, 255, 255),
12574            },
12575        ]);
12576        let (colors, offsets, count) = pack_gradient_stops(&stops);
12577        assert_eq!(count, 4);
12578        assert_eq!(offsets, [[0.25, 0.25, 0.25, 1.0], [0.0, 0.0, 0.0, 0.0]]);
12579        assert_eq!(colors[0], [1.0, 0.0, 0.0, 1.0]);
12580        assert_eq!(colors[1], [0.0, 1.0, 0.0, 1.0]);
12581        assert_eq!(colors[2], [0.0, 0.0, 1.0, 1.0]);
12582        assert_eq!(colors[3], [1.0, 1.0, 1.0, 1.0]);
12583        assert_eq!(
12584            pack_gradient_stops(&GradientStops::default()).2,
12585            0,
12586            "an empty table paints nothing"
12587        );
12588    }
12589
12590    /// A repack moves every surviving glyph, so eviction must never touch one
12591    /// the in-flight frame already holds UVs for, and must never empty the map.
12592    /// Glyph 1 is the coldest overall but in flight, so it has to survive even
12593    /// though glyphs 3 and 4 are warmer and evictable.
12594    #[test]
12595    fn atlas_eviction_spares_the_frame_in_flight() {
12596        fn key(glyph_id: u32) -> repose_text::CacheKey {
12597            repose_text::CacheKey {
12598                font_id: 1,
12599                glyph_id,
12600                font_size_bits: 16.0f32.to_bits(),
12601                variation: None,
12602            }
12603        }
12604        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12605        for (glyph_id, last_touch, last_used_frame) in
12606            [(1u32, 10u64, 9u64), (2, 20, 7), (3, 30, 7), (4, 40, 7)]
12607        {
12608            map.insert(
12609                key(glyph_id),
12610                GlyphInfo {
12611                    u0: 0.0,
12612                    v0: 0.0,
12613                    u1: 0.1,
12614                    v1: 0.1,
12615                    w: 10.0,
12616                    h: 10.0,
12617                    last_touch,
12618                    last_used_frame,
12619                },
12620            );
12621        }
12622        let mut used_area = 400;
12623        assert!(evict_atlas_lru(&mut map, &mut used_area, 9, 250));
12624        assert_eq!(used_area, 200);
12625        assert!(map.contains_key(&key(1)), "in-flight glyph was evicted");
12626        assert!(!map.contains_key(&key(2)) && !map.contains_key(&key(3)));
12627        assert!(map.contains_key(&key(4)));
12628    }
12629
12630    #[test]
12631    fn atlas_eviction_refuses_to_empty_the_atlas() {
12632        let only = repose_text::CacheKey {
12633            font_id: 1,
12634            glyph_id: 1,
12635            font_size_bits: 16.0f32.to_bits(),
12636            variation: None,
12637        };
12638        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12639        map.insert(
12640            only,
12641            GlyphInfo {
12642                u0: 0.0,
12643                v0: 0.0,
12644                u1: 0.1,
12645                v1: 0.1,
12646                w: 10.0,
12647                h: 10.0,
12648                last_touch: 1,
12649                last_used_frame: 1,
12650            },
12651        );
12652        let mut used_area = 100;
12653        assert!(!evict_atlas_lru(&mut map, &mut used_area, 2, 0));
12654        assert_eq!(map.len(), 1);
12655        assert_eq!(used_area, 100);
12656    }
12657
12658    #[test]
12659    fn empty_and_negative_rects_are_suppressed() {
12660        assert_eq!(
12661            rect_to_scissor(
12662                repose_core::Rect {
12663                    x: 0.0,
12664                    y: 0.0,
12665                    w: 0.0,
12666                    h: 4.0,
12667                },
12668                16,
12669                16,
12670            ),
12671            (0, 0, 0, 0)
12672        );
12673        assert_eq!(
12674            rect_to_scissor(
12675                repose_core::Rect {
12676                    x: 0.0,
12677                    y: 0.0,
12678                    w: -1.0,
12679                    h: 4.0,
12680                },
12681                16,
12682                16,
12683            ),
12684            (0, 0, 0, 0)
12685        );
12686    }
12687
12688    #[test]
12689    fn copy_region_is_checked() {
12690        assert_eq!(
12691            checked_copy_region(
12692                repose_core::Rect {
12693                    x: 2.25,
12694                    y: 3.5,
12695                    w: 4.0,
12696                    h: 5.0,
12697                },
12698                16,
12699                16,
12700            ),
12701            Some((2, 3, 5, 6))
12702        );
12703        assert_eq!(
12704            checked_copy_region(
12705                repose_core::Rect {
12706                    x: 20.0,
12707                    y: 0.0,
12708                    w: 2.0,
12709                    h: 2.0,
12710                },
12711                16,
12712                16,
12713            ),
12714            None
12715        );
12716    }
12717
12718    #[test]
12719    fn decode_budget_rejects_oversized_encoded_images() {
12720        assert!(validate_image_decode_budget(64, 64, 8192).is_ok());
12721        assert!(validate_image_decode_budget(0, 64, 8192).is_err());
12722        assert!(validate_image_decode_budget(64, 0, 8192).is_err());
12723        assert!(validate_image_decode_budget(8193, 1, 8192).is_err());
12724        assert!(validate_image_decode_budget(1, 8193, 8192).is_err());
12725        // Fits the device, but not the decode allocation budget.
12726        assert!(validate_image_decode_budget(8192, 4096, 8192).is_err());
12727    }
12728
12729    #[test]
12730    fn only_wgpu_supported_sample_counts_are_accepted() {
12731        for samples in [1, 2, 4, 8, 16] {
12732            assert!(validate_sample_count("msaa_samples", samples).is_ok());
12733        }
12734        for samples in [0, 3, 5, 7, 9, 17, 32, u32::MAX] {
12735            assert!(
12736                validate_sample_count("msaa_samples", samples).is_err(),
12737                "{samples} must be rejected"
12738            );
12739        }
12740    }
12741
12742    #[test]
12743    fn p010_uses_ten_bit_sample_range() {
12744        let raw = make_yuv_transform_raw(ColorInfo::default(), PixelFormat::P010);
12745        let expected = 65535.0 / (64.0 * 1020.0);
12746        assert!((raw.b[3] - expected).abs() < 1e-6);
12747        assert_eq!(canonical_fourcc(PixelFormat::P010), P010_FOURCC);
12748        assert_eq!(canonical_fourcc(PixelFormat::Nv12), NV12_FOURCC);
12749        assert_eq!(
12750            make_yuv_transform_raw(
12751                ColorInfo {
12752                    transfer: repose_core::color::Transfer::Pq,
12753                    ..ColorInfo::default()
12754                },
12755                PixelFormat::Nv12,
12756            )
12757            .transfer[0],
12758            3.0
12759        );
12760        assert_eq!(
12761            make_yuv_transform_raw(
12762                ColorInfo {
12763                    transfer: repose_core::color::Transfer::Hlg,
12764                    ..ColorInfo::default()
12765                },
12766                PixelFormat::Nv12,
12767            )
12768            .transfer[0],
12769            4.0
12770        );
12771    }
12772}