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                        "shaders/", $shader, ".wgsl"
801                    )))),
802                });
803                let pipeline_layout =
804                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
805                        label: Some(concat!($shader, " pipeline layout")),
806                        bind_group_layouts: &[Some(globals_layout)],
807                        immediate_size: 0,
808                    });
809                let $name = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
810                    label: Some(concat!($shader, " pipeline")),
811                    layout: Some(&pipeline_layout),
812                    vertex: wgpu::VertexState {
813                        module: &shader_module,
814                        entry_point: Some("vs_main"),
815                        buffers: &[Some(wgpu::VertexBufferLayout {
816                            array_stride: std::mem::size_of::<$inst_type>() as u64,
817                            step_mode: wgpu::VertexStepMode::Instance,
818                            attributes: $attrs,
819                        })],
820                        compilation_options: wgpu::PipelineCompilationOptions::default(),
821                    },
822                    fragment: Some(wgpu::FragmentState {
823                        module: &shader_module,
824                        entry_point: Some("fs_main"),
825                        targets: &[Some(wgpu::ColorTargetState {
826                            format,
827                            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
828                            write_mask: wgpu::ColorWrites::ALL,
829                        })],
830                        compilation_options: wgpu::PipelineCompilationOptions::default(),
831                    }),
832                    primitive: wgpu::PrimitiveState::default(),
833                    depth_stencil: Some(stencil_for_content.clone()),
834                    multisample: msaa_state,
835                    multiview_mask: None,
836                    cache: None,
837                });
838            };
839        }
840
841        let rect_attrs = vattrs(&[
842            (
843                0,
844                field_offset!(RectInstance, xywh),
845                wgpu::VertexFormat::Float32x4,
846            ),
847            (
848                1,
849                field_offset!(RectInstance, radii),
850                wgpu::VertexFormat::Float32x4,
851            ),
852            (
853                2,
854                field_offset!(RectInstance, color0),
855                wgpu::VertexFormat::Float32x4,
856            ),
857            (
858                3,
859                field_offset!(RectInstance, color1),
860                wgpu::VertexFormat::Float32x4,
861            ),
862            (
863                4,
864                field_offset!(RectInstance, fwd_mat),
865                wgpu::VertexFormat::Float32x4,
866            ),
867            (
868                5,
869                field_offset!(RectInstance, grad),
870                wgpu::VertexFormat::Float32x4,
871            ),
872            (
873                6,
874                field_offset!(RectInstance, bits),
875                wgpu::VertexFormat::Uint32,
876            ),
877        ]);
878        let border_attrs = vattrs(&[
879            (
880                0,
881                field_offset!(BorderInstance, xywh),
882                wgpu::VertexFormat::Float32x4,
883            ),
884            (
885                1,
886                field_offset!(BorderInstance, radii),
887                wgpu::VertexFormat::Float32x4,
888            ),
889            (
890                2,
891                field_offset!(BorderInstance, color0),
892                wgpu::VertexFormat::Float32x4,
893            ),
894            (
895                3,
896                field_offset!(BorderInstance, color1),
897                wgpu::VertexFormat::Float32x4,
898            ),
899            (
900                4,
901                field_offset!(BorderInstance, fwd_mat),
902                wgpu::VertexFormat::Float32x4,
903            ),
904            (
905                5,
906                field_offset!(BorderInstance, grad),
907                wgpu::VertexFormat::Float32x4,
908            ),
909            (
910                6,
911                field_offset!(BorderInstance, bits),
912                wgpu::VertexFormat::Uint32,
913            ),
914            (
915                7,
916                field_offset!(BorderInstance, stroke),
917                wgpu::VertexFormat::Float32,
918            ),
919        ]);
920        let ellipse_attrs = vattrs(&[
921            (
922                0,
923                field_offset!(EllipseInstance, xywh),
924                wgpu::VertexFormat::Float32x4,
925            ),
926            (
927                1,
928                field_offset!(EllipseInstance, color0),
929                wgpu::VertexFormat::Float32x4,
930            ),
931            (
932                2,
933                field_offset!(EllipseInstance, color1),
934                wgpu::VertexFormat::Float32x4,
935            ),
936            (
937                3,
938                field_offset!(EllipseInstance, fwd_mat),
939                wgpu::VertexFormat::Float32x4,
940            ),
941            (
942                4,
943                field_offset!(EllipseInstance, grad),
944                wgpu::VertexFormat::Float32x4,
945            ),
946            (
947                5,
948                field_offset!(EllipseInstance, bits),
949                wgpu::VertexFormat::Uint32,
950            ),
951        ]);
952        let ellipse_border_attrs = vattrs(&[
953            (
954                0,
955                field_offset!(EllipseBorderInstance, xywh),
956                wgpu::VertexFormat::Float32x4,
957            ),
958            (
959                1,
960                field_offset!(EllipseBorderInstance, color0),
961                wgpu::VertexFormat::Float32x4,
962            ),
963            (
964                2,
965                field_offset!(EllipseBorderInstance, color1),
966                wgpu::VertexFormat::Float32x4,
967            ),
968            (
969                3,
970                field_offset!(EllipseBorderInstance, fwd_mat),
971                wgpu::VertexFormat::Float32x4,
972            ),
973            (
974                4,
975                field_offset!(EllipseBorderInstance, grad),
976                wgpu::VertexFormat::Float32x4,
977            ),
978            (
979                5,
980                field_offset!(EllipseBorderInstance, bits),
981                wgpu::VertexFormat::Uint32,
982            ),
983            (
984                6,
985                field_offset!(EllipseBorderInstance, stroke),
986                wgpu::VertexFormat::Float32,
987            ),
988            (
989                7,
990                field_offset!(EllipseBorderInstance, pad),
991                wgpu::VertexFormat::Float32,
992            ),
993        ]);
994
995        make_content_pipeline!(rects, "rect", RectInstance, &rect_attrs);
996        make_content_pipeline!(borders, "border", BorderInstance, &border_attrs);
997        make_content_pipeline!(ellipses, "ellipse", EllipseInstance, &ellipse_attrs);
998        make_content_pipeline!(
999            ellipse_borders,
1000            "ellipse_border",
1001            EllipseBorderInstance,
1002            &ellipse_border_attrs
1003        );
1004
1005        let arc_attrs = vattrs(&[
1006            (
1007                0,
1008                field_offset!(ArcInstance, xywh),
1009                wgpu::VertexFormat::Float32x4,
1010            ),
1011            (
1012                1,
1013                field_offset!(ArcInstance, color0),
1014                wgpu::VertexFormat::Float32x4,
1015            ),
1016            (
1017                2,
1018                field_offset!(ArcInstance, color1),
1019                wgpu::VertexFormat::Float32x4,
1020            ),
1021            (
1022                3,
1023                field_offset!(ArcInstance, fwd_mat),
1024                wgpu::VertexFormat::Float32x4,
1025            ),
1026            (
1027                4,
1028                field_offset!(ArcInstance, grad),
1029                wgpu::VertexFormat::Float32x4,
1030            ),
1031            (
1032                5,
1033                field_offset!(ArcInstance, bits),
1034                wgpu::VertexFormat::Uint32,
1035            ),
1036            (
1037                6,
1038                field_offset!(ArcInstance, start_angle),
1039                wgpu::VertexFormat::Float32,
1040            ),
1041            (
1042                7,
1043                field_offset!(ArcInstance, sweep_angle),
1044                wgpu::VertexFormat::Float32,
1045            ),
1046            (
1047                8,
1048                field_offset!(ArcInstance, stroke),
1049                wgpu::VertexFormat::Float32,
1050            ),
1051            (
1052                9,
1053                field_offset!(ArcInstance, pad),
1054                wgpu::VertexFormat::Float32,
1055            ),
1056        ]);
1057
1058        make_content_pipeline!(arcs, "arc", ArcInstance, &arc_attrs);
1059
1060        // Text (mask)
1061        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1062            label: Some("text.wgsl"),
1063            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
1064        });
1065        // Text (color)
1066        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1067            label: Some("text_color.wgsl"),
1068            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1069                "shaders/text_color.wgsl"
1070            ))),
1071        });
1072        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1073            label: Some("text pipeline layout"),
1074            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1075            immediate_size: 0,
1076        });
1077        let glyph_vertex = wgpu::VertexBufferLayout {
1078            array_stride: std::mem::size_of::<GlyphInstance>() as u64,
1079            step_mode: wgpu::VertexStepMode::Instance,
1080            attributes: &[
1081                wgpu::VertexAttribute {
1082                    shader_location: 0,
1083                    offset: 0,
1084                    format: wgpu::VertexFormat::Float32x4,
1085                },
1086                wgpu::VertexAttribute {
1087                    shader_location: 1,
1088                    offset: 16,
1089                    format: wgpu::VertexFormat::Float32x4,
1090                },
1091                wgpu::VertexAttribute {
1092                    shader_location: 2,
1093                    offset: 32,
1094                    format: wgpu::VertexFormat::Float32x4,
1095                },
1096                wgpu::VertexAttribute {
1097                    shader_location: 3,
1098                    offset: 48,
1099                    format: wgpu::VertexFormat::Float32x4,
1100                },
1101            ],
1102        };
1103        let text_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1104            label: Some("text pipeline (mask)"),
1105            layout: Some(&text_pipeline_layout),
1106            vertex: wgpu::VertexState {
1107                module: &text_mask_shader,
1108                entry_point: Some("vs_main"),
1109                buffers: &[Some(glyph_vertex.clone())],
1110                compilation_options: wgpu::PipelineCompilationOptions::default(),
1111            },
1112            fragment: Some(wgpu::FragmentState {
1113                module: &text_mask_shader,
1114                entry_point: Some("fs_main"),
1115                targets: &[Some(wgpu::ColorTargetState {
1116                    format,
1117                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1118                    write_mask: wgpu::ColorWrites::ALL,
1119                })],
1120                compilation_options: wgpu::PipelineCompilationOptions::default(),
1121            }),
1122            primitive: wgpu::PrimitiveState::default(),
1123            depth_stencil: Some(stencil_for_content.clone()),
1124            multisample: msaa_state,
1125            multiview_mask: None,
1126            cache: None,
1127        });
1128        let text_color_pipeline =
1129            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1130                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1131                    label: Some(label),
1132                    layout: Some(&text_pipeline_layout),
1133                    vertex: wgpu::VertexState {
1134                        module: &text_color_shader,
1135                        entry_point: Some("vs_main"),
1136                        buffers: &[Some(glyph_vertex.clone())],
1137                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1138                    },
1139                    fragment: Some(wgpu::FragmentState {
1140                        module: &text_color_shader,
1141                        entry_point: Some("fs_main"),
1142                        targets: &[Some(wgpu::ColorTargetState {
1143                            format,
1144                            blend: Some(blend),
1145                            write_mask: wgpu::ColorWrites::ALL,
1146                        })],
1147                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1148                    }),
1149                    primitive: wgpu::PrimitiveState::default(),
1150                    depth_stencil: Some(stencil_for_content.clone()),
1151                    multisample: msaa_state,
1152                    multiview_mask: None,
1153                    cache: None,
1154                })
1155            };
1156        let text_color = text_color_pipeline(
1157            "text pipeline (color)",
1158            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1159        );
1160        // image_rgba reuses the text color pipeline (same vertex/bindings).
1161        let image_rgba = text_color.clone();
1162        // Fixed-function blend variants for `ImageBlended`, indexed like
1163        // `FIXED_FUNCTION_BLENDS`.
1164        let image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1165            std::array::from_fn(|index| {
1166                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1167                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1168                text_color_pipeline("image pipeline (blend)", blend)
1169            });
1170
1171        // Tinted A8 coverage composite. Same vertex attributes (GlyphInstance)
1172        // and bind groups (globals + texture/sampler) as the text color path,
1173        // sampling R8 tiles uploaded via `register_coverage_a8`.
1174        let coverage_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1175            label: Some("coverage.wgsl"),
1176            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/coverage.wgsl"))),
1177        });
1178        let coverage = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1179            label: Some("coverage pipeline (tinted a8)"),
1180            layout: Some(&text_pipeline_layout),
1181            vertex: wgpu::VertexState {
1182                module: &coverage_shader,
1183                entry_point: Some("vs_main"),
1184                buffers: &[Some(glyph_vertex.clone())],
1185                compilation_options: wgpu::PipelineCompilationOptions::default(),
1186            },
1187            fragment: Some(wgpu::FragmentState {
1188                module: &coverage_shader,
1189                entry_point: Some("fs_main"),
1190                targets: &[Some(wgpu::ColorTargetState {
1191                    format,
1192                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1193                    write_mask: wgpu::ColorWrites::ALL,
1194                })],
1195                compilation_options: wgpu::PipelineCompilationOptions::default(),
1196            }),
1197            primitive: wgpu::PrimitiveState::default(),
1198            depth_stencil: Some(stencil_for_content.clone()),
1199            multisample: msaa_state,
1200            multiview_mask: None,
1201            cache: None,
1202        });
1203
1204        let blur_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1205            label: Some("blur_separable.wgsl"),
1206            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1207                "shaders/blur_separable.wgsl"
1208            ))),
1209        });
1210        let blur_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1211            label: Some("blur pipeline layout"),
1212            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1213            immediate_size: 0,
1214        });
1215        let blur_layout = wgpu::VertexBufferLayout {
1216            array_stride: std::mem::size_of::<BlurInstance>() as u64,
1217            step_mode: wgpu::VertexStepMode::Instance,
1218            attributes: &[
1219                wgpu::VertexAttribute {
1220                    shader_location: 0,
1221                    offset: 0,
1222                    format: wgpu::VertexFormat::Float32x4,
1223                },
1224                wgpu::VertexAttribute {
1225                    shader_location: 1,
1226                    offset: 16,
1227                    format: wgpu::VertexFormat::Float32x4,
1228                },
1229                wgpu::VertexAttribute {
1230                    shader_location: 2,
1231                    offset: 32,
1232                    format: wgpu::VertexFormat::Float32x4,
1233                },
1234                wgpu::VertexAttribute {
1235                    shader_location: 3,
1236                    offset: 48,
1237                    format: wgpu::VertexFormat::Float32x2,
1238                },
1239                wgpu::VertexAttribute {
1240                    shader_location: 4,
1241                    offset: 56,
1242                    format: wgpu::VertexFormat::Float32x4,
1243                },
1244                wgpu::VertexAttribute {
1245                    shader_location: 5,
1246                    offset: 72,
1247                    format: wgpu::VertexFormat::Uint32,
1248                },
1249                wgpu::VertexAttribute {
1250                    shader_location: 6,
1251                    offset: 76,
1252                    format: wgpu::VertexFormat::Uint32,
1253                },
1254                wgpu::VertexAttribute {
1255                    shader_location: 7,
1256                    offset: 80,
1257                    format: wgpu::VertexFormat::Float32x4,
1258                },
1259            ],
1260        };
1261        let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1262            label: Some("blur pipeline"),
1263            layout: Some(&blur_pipeline_layout),
1264            vertex: wgpu::VertexState {
1265                module: &blur_shader,
1266                entry_point: Some("vs_main"),
1267                buffers: &[Some(blur_layout.clone())],
1268                compilation_options: wgpu::PipelineCompilationOptions::default(),
1269            },
1270            fragment: Some(wgpu::FragmentState {
1271                module: &blur_shader,
1272                entry_point: Some("fs_alpha"),
1273                targets: &[Some(wgpu::ColorTargetState {
1274                    format,
1275                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1276                    write_mask: wgpu::ColorWrites::ALL,
1277                })],
1278                compilation_options: wgpu::PipelineCompilationOptions::default(),
1279            }),
1280            primitive: wgpu::PrimitiveState::default(),
1281            depth_stencil: Some(stencil_for_content.clone()),
1282            multisample: msaa_state,
1283            multiview_mask: None,
1284            cache: None,
1285        });
1286
1287        let blur_content = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1288            label: Some("blur content pipeline"),
1289            layout: Some(&blur_pipeline_layout),
1290            vertex: wgpu::VertexState {
1291                module: &blur_shader,
1292                entry_point: Some("vs_main"),
1293                buffers: &[Some(blur_layout)],
1294                compilation_options: wgpu::PipelineCompilationOptions::default(),
1295            },
1296            fragment: Some(wgpu::FragmentState {
1297                module: &blur_shader,
1298                entry_point: Some("fs_color"),
1299                targets: &[Some(wgpu::ColorTargetState {
1300                    format,
1301                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1302                    write_mask: wgpu::ColorWrites::ALL,
1303                })],
1304                compilation_options: wgpu::PipelineCompilationOptions::default(),
1305            }),
1306            primitive: wgpu::PrimitiveState::default(),
1307            depth_stencil: Some(stencil_for_content.clone()),
1308            multisample: msaa_state,
1309            multiview_mask: None,
1310            cache: None,
1311        });
1312
1313        // NV12 Image Pipeline
1314        let image_nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1315            label: Some("image_nv12.wgsl"),
1316            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1317                "shaders/image_nv12.wgsl"
1318            ))),
1319        });
1320        let image_nv12_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1321            label: Some("image nv12 pipeline layout"),
1322            bind_group_layouts: &[Some(globals_layout), Some(image_bind_layout_nv12)],
1323            immediate_size: 0,
1324        });
1325        let nv12_vertex_layout = wgpu::VertexBufferLayout {
1326            array_stride: std::mem::size_of::<Nv12Instance>() as u64,
1327            step_mode: wgpu::VertexStepMode::Instance,
1328            attributes: &[
1329                wgpu::VertexAttribute {
1330                    shader_location: 0,
1331                    offset: 0,
1332                    format: wgpu::VertexFormat::Float32x4,
1333                },
1334                wgpu::VertexAttribute {
1335                    shader_location: 1,
1336                    offset: 16,
1337                    format: wgpu::VertexFormat::Float32x4,
1338                },
1339                wgpu::VertexAttribute {
1340                    shader_location: 2,
1341                    offset: 32,
1342                    format: wgpu::VertexFormat::Float32x4,
1343                },
1344                wgpu::VertexAttribute {
1345                    shader_location: 3,
1346                    offset: 48,
1347                    format: wgpu::VertexFormat::Float32,
1348                },
1349                wgpu::VertexAttribute {
1350                    shader_location: 4,
1351                    offset: 52,
1352                    format: wgpu::VertexFormat::Float32,
1353                },
1354                wgpu::VertexAttribute {
1355                    shader_location: 5,
1356                    offset: 56,
1357                    format: wgpu::VertexFormat::Float32x4,
1358                },
1359            ],
1360        };
1361        let nv12_pipeline =
1362            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1363                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1364                    label: Some(label),
1365                    layout: Some(&image_nv12_layout),
1366                    vertex: wgpu::VertexState {
1367                        module: &image_nv12_shader,
1368                        entry_point: Some("vs_main"),
1369                        buffers: &[Some(nv12_vertex_layout.clone())],
1370                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1371                    },
1372                    fragment: Some(wgpu::FragmentState {
1373                        module: &image_nv12_shader,
1374                        entry_point: Some("fs_main"),
1375                        targets: &[Some(wgpu::ColorTargetState {
1376                            format,
1377                            blend: Some(blend),
1378                            write_mask: wgpu::ColorWrites::ALL,
1379                        })],
1380                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1381                    }),
1382                    primitive: wgpu::PrimitiveState::default(),
1383                    depth_stencil: Some(stencil_for_content.clone()),
1384                    multisample: msaa_state,
1385                    multiview_mask: None,
1386                    cache: None,
1387                })
1388            };
1389        let image_nv12 = nv12_pipeline(
1390            "image nv12 pipeline",
1391            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1392        );
1393        let image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1394            std::array::from_fn(|index| {
1395                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1396                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1397                nv12_pipeline("image nv12 pipeline (blend)", blend)
1398            });
1399
1400        let clip_color_target = wgpu::ColorTargetState {
1401            format,
1402            blend: None,
1403            write_mask: wgpu::ColorWrites::empty(),
1404        };
1405
1406        // Clipping
1407        let clip_shader_bin = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1408            label: Some("clip_round_rect_bin.wgsl"),
1409            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1410                "shaders/clip_round_rect_bin.wgsl"
1411            ))),
1412        });
1413        let clip_bin = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1414            label: Some("clip pipeline (bin)"),
1415            layout: Some(clip_pipeline_layout),
1416            vertex: wgpu::VertexState {
1417                module: &clip_shader_bin,
1418                entry_point: Some("vs_main"),
1419                buffers: &[Some(clip_vertex_layout.clone())],
1420                compilation_options: wgpu::PipelineCompilationOptions::default(),
1421            },
1422            fragment: Some(wgpu::FragmentState {
1423                module: &clip_shader_bin,
1424                entry_point: Some("fs_main"),
1425                targets: &[Some(clip_color_target.clone())],
1426                compilation_options: wgpu::PipelineCompilationOptions::default(),
1427            }),
1428            primitive: wgpu::PrimitiveState::default(),
1429            depth_stencil: Some(stencil_for_clip_inc.clone()),
1430            multisample: wgpu::MultisampleState {
1431                count: sample_count,
1432                mask: !0,
1433                alpha_to_coverage_enabled: false,
1434            },
1435            multiview_mask: None,
1436            cache: None,
1437        });
1438        let clip_dec = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1439            label: Some("clip pipeline (dec)"),
1440            layout: Some(clip_pipeline_layout),
1441            vertex: wgpu::VertexState {
1442                module: &clip_shader_bin,
1443                entry_point: Some("vs_main"),
1444                buffers: &[Some(clip_vertex_layout.clone())],
1445                compilation_options: wgpu::PipelineCompilationOptions::default(),
1446            },
1447            fragment: Some(wgpu::FragmentState {
1448                module: &clip_shader_bin,
1449                entry_point: Some("fs_main"),
1450                targets: &[Some(clip_color_target.clone())],
1451                compilation_options: wgpu::PipelineCompilationOptions::default(),
1452            }),
1453            primitive: wgpu::PrimitiveState::default(),
1454            depth_stencil: Some(stencil_for_clip_dec.clone()),
1455            multisample: wgpu::MultisampleState {
1456                count: sample_count,
1457                mask: !0,
1458                alpha_to_coverage_enabled: false,
1459            },
1460            multiview_mask: None,
1461            cache: None,
1462        });
1463
1464        let slug = Some(slug::create_pipeline(
1465            device,
1466            format,
1467            sample_count,
1468            stencil_for_content,
1469        ));
1470
1471        // Tessellated vector mesh pipeline (host-supplied vertex/index data).
1472        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1473            label: Some("mesh.wgsl"),
1474            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/mesh.wgsl"))),
1475        });
1476        let mesh_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1477            label: Some("mesh pipeline layout"),
1478            bind_group_layouts: &[Some(globals_layout), Some(mesh_bind_layout)],
1479            immediate_size: 0,
1480        });
1481        let mesh_vertex_layout = wgpu::VertexBufferLayout {
1482            array_stride: std::mem::size_of::<MeshVertex>() as u64,
1483            step_mode: wgpu::VertexStepMode::Vertex,
1484            attributes: &[
1485                wgpu::VertexAttribute {
1486                    shader_location: 0,
1487                    offset: 0,
1488                    format: wgpu::VertexFormat::Float32x2,
1489                },
1490                wgpu::VertexAttribute {
1491                    shader_location: 1,
1492                    offset: 8,
1493                    format: wgpu::VertexFormat::Float32x4,
1494                },
1495                wgpu::VertexAttribute {
1496                    shader_location: 2,
1497                    offset: 24,
1498                    format: wgpu::VertexFormat::Float32x2,
1499                },
1500            ],
1501        };
1502        let make_mesh_pipeline =
1503            |label: &str, depth: &wgpu::DepthStencilState, color: &wgpu::ColorTargetState| {
1504                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1505                    label: Some(label),
1506                    layout: Some(&mesh_pipeline_layout),
1507                    vertex: wgpu::VertexState {
1508                        module: &mesh_shader,
1509                        entry_point: Some("vs_main"),
1510                        buffers: &[Some(mesh_vertex_layout.clone())],
1511                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1512                    },
1513                    fragment: Some(wgpu::FragmentState {
1514                        module: &mesh_shader,
1515                        entry_point: Some("fs_main"),
1516                        targets: &[Some(color.clone())],
1517                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1518                    }),
1519                    primitive: wgpu::PrimitiveState {
1520                        topology: wgpu::PrimitiveTopology::TriangleList,
1521                        ..Default::default()
1522                    },
1523                    depth_stencil: Some(depth.clone()),
1524                    multisample: msaa_state,
1525                    multiview_mask: None,
1526                    cache: None,
1527                })
1528            };
1529        let mesh_color_target = wgpu::ColorTargetState {
1530            format,
1531            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1532            write_mask: wgpu::ColorWrites::ALL,
1533        };
1534        let mut stencil_for_mesh = stencil_for_content.clone();
1535        stencil_for_mesh.stencil.front.compare = wgpu::CompareFunction::Equal;
1536        stencil_for_mesh.stencil.back.compare = wgpu::CompareFunction::Equal;
1537        let mut stencil_for_overlay = stencil_for_content.clone();
1538        stencil_for_overlay.stencil.front.compare = wgpu::CompareFunction::LessEqual;
1539        stencil_for_overlay.stencil.back.compare = wgpu::CompareFunction::LessEqual;
1540        let mesh = make_mesh_pipeline("mesh pipeline", &stencil_for_mesh, &mesh_color_target);
1541        let mesh_overlay = make_mesh_pipeline(
1542            "mesh overlay pipeline",
1543            &stencil_for_overlay,
1544            &mesh_color_target,
1545        );
1546        let mesh_clip_inc = make_mesh_pipeline(
1547            "mesh clip (inc) pipeline",
1548            stencil_for_clip_inc,
1549            &clip_color_target,
1550        );
1551        let mesh_clip_dec = make_mesh_pipeline(
1552            "mesh clip (dec) pipeline",
1553            stencil_for_clip_dec,
1554            &clip_color_target,
1555        );
1556        let mesh_blend_target = |blend: wgpu::BlendState| wgpu::ColorTargetState {
1557            format,
1558            blend: Some(blend),
1559            write_mask: wgpu::ColorWrites::ALL,
1560        };
1561        let mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1562            std::array::from_fn(|index| {
1563                let mode = FIXED_FUNCTION_BLENDS[index];
1564                let blend = fixed_function_blend(mode).unwrap_or(wgpu::BlendState::REPLACE);
1565                make_mesh_pipeline(
1566                    match mode {
1567                        repose_core::BlendMode::Add => "mesh pipeline (add)",
1568                        repose_core::BlendMode::Multiply => "mesh pipeline (multiply)",
1569                        repose_core::BlendMode::Screen => "mesh pipeline (screen)",
1570                        repose_core::BlendMode::Darken => "mesh pipeline (darken)",
1571                        _ => "mesh pipeline (lighten)",
1572                    },
1573                    &stencil_for_mesh,
1574                    &mesh_blend_target(blend),
1575                )
1576            });
1577        // Projective layer composite (perspective flattening). Same
1578        // bind groups as the text/image path (globals + layer texture), with
1579        // per-instance projected corners. Like `image_rgba` it draws into the
1580        // parent target, so it shares the content stencil state.
1581        let projective_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1582            label: Some("projective_layer.wgsl"),
1583            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1584                "shaders/projective_layer.wgsl"
1585            ))),
1586        });
1587        let projective_pipeline_layout =
1588            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1589                label: Some("projective layer pipeline layout"),
1590                bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1591                immediate_size: 0,
1592            });
1593        let projective_vertex_layout = wgpu::VertexBufferLayout {
1594            array_stride: std::mem::size_of::<ProjectiveInstance>() as u64,
1595            step_mode: wgpu::VertexStepMode::Instance,
1596            attributes: &[
1597                wgpu::VertexAttribute {
1598                    shader_location: 0,
1599                    offset: 0,
1600                    format: wgpu::VertexFormat::Float32x2,
1601                },
1602                wgpu::VertexAttribute {
1603                    shader_location: 1,
1604                    offset: 8,
1605                    format: wgpu::VertexFormat::Float32x2,
1606                },
1607                wgpu::VertexAttribute {
1608                    shader_location: 2,
1609                    offset: 16,
1610                    format: wgpu::VertexFormat::Float32x2,
1611                },
1612                wgpu::VertexAttribute {
1613                    shader_location: 3,
1614                    offset: 24,
1615                    format: wgpu::VertexFormat::Float32x2,
1616                },
1617                wgpu::VertexAttribute {
1618                    shader_location: 4,
1619                    offset: 32,
1620                    format: wgpu::VertexFormat::Float32x4,
1621                },
1622                wgpu::VertexAttribute {
1623                    shader_location: 5,
1624                    offset: 48,
1625                    format: wgpu::VertexFormat::Float32x4,
1626                },
1627                wgpu::VertexAttribute {
1628                    shader_location: 6,
1629                    offset: 64,
1630                    format: wgpu::VertexFormat::Float32,
1631                },
1632            ],
1633        };
1634        let projective_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1635            label: Some("projective layer composite pipeline"),
1636            layout: Some(&projective_pipeline_layout),
1637            vertex: wgpu::VertexState {
1638                module: &projective_shader,
1639                entry_point: Some("vs_main"),
1640                buffers: &[Some(projective_vertex_layout)],
1641                compilation_options: wgpu::PipelineCompilationOptions::default(),
1642            },
1643            fragment: Some(wgpu::FragmentState {
1644                module: &projective_shader,
1645                entry_point: Some("fs_main"),
1646                targets: &[Some(wgpu::ColorTargetState {
1647                    format,
1648                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1649                    write_mask: wgpu::ColorWrites::ALL,
1650                })],
1651                compilation_options: wgpu::PipelineCompilationOptions::default(),
1652            }),
1653            primitive: wgpu::PrimitiveState::default(),
1654            depth_stencil: Some(stencil_for_content.clone()),
1655            multisample: msaa_state,
1656            multiview_mask: None,
1657            cache: None,
1658        });
1659
1660        let blend_layer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1661            label: Some("blend_layer.wgsl"),
1662            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1663                "shaders/blend_layer.wgsl"
1664            ))),
1665        });
1666        let blend_layer_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1667            label: Some("blend layer pipeline layout"),
1668            bind_group_layouts: &[
1669                Some(globals_layout),
1670                Some(text_bind_layout),
1671                Some(text_bind_layout),
1672            ],
1673            immediate_size: 0,
1674        });
1675        let blend_vertex_layout = wgpu::VertexBufferLayout {
1676            array_stride: std::mem::size_of::<BlendInstance>() as u64,
1677            step_mode: wgpu::VertexStepMode::Instance,
1678            attributes: &[
1679                wgpu::VertexAttribute {
1680                    shader_location: 0,
1681                    offset: 0,
1682                    format: wgpu::VertexFormat::Float32x4,
1683                },
1684                wgpu::VertexAttribute {
1685                    shader_location: 1,
1686                    offset: 16,
1687                    format: wgpu::VertexFormat::Float32x4,
1688                },
1689                wgpu::VertexAttribute {
1690                    shader_location: 2,
1691                    offset: 32,
1692                    format: wgpu::VertexFormat::Float32x4,
1693                },
1694                wgpu::VertexAttribute {
1695                    shader_location: 3,
1696                    offset: 48,
1697                    format: wgpu::VertexFormat::Float32x4,
1698                },
1699                wgpu::VertexAttribute {
1700                    shader_location: 4,
1701                    offset: 64,
1702                    format: wgpu::VertexFormat::Uint32,
1703                },
1704            ],
1705        };
1706        let blend_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1707            label: Some("blend layer pipeline"),
1708            layout: Some(&blend_layer_layout),
1709            vertex: wgpu::VertexState {
1710                module: &blend_layer_shader,
1711                entry_point: Some("vs_main"),
1712                buffers: &[Some(blend_vertex_layout)],
1713                compilation_options: wgpu::PipelineCompilationOptions::default(),
1714            },
1715            fragment: Some(wgpu::FragmentState {
1716                module: &blend_layer_shader,
1717                entry_point: Some("fs_main"),
1718                targets: &[Some(wgpu::ColorTargetState {
1719                    format,
1720                    blend: None,
1721                    write_mask: wgpu::ColorWrites::ALL,
1722                })],
1723                compilation_options: wgpu::PipelineCompilationOptions::default(),
1724            }),
1725            primitive: wgpu::PrimitiveState::default(),
1726            depth_stencil: Some(stencil_for_content.clone()),
1727            multisample: msaa_state,
1728            multiview_mask: None,
1729            cache: None,
1730        });
1731
1732        Self {
1733            rects,
1734            borders,
1735            ellipses,
1736            ellipse_borders,
1737            arcs,
1738            text_mask,
1739            text_color,
1740            image_rgba,
1741            image_blends,
1742            image_nv12,
1743            image_nv12_blends,
1744            coverage,
1745            blur,
1746            blur_content,
1747            clip_bin,
1748            clip_dec,
1749            slug,
1750            mesh,
1751            mesh_blends,
1752            blend_layer,
1753            mesh_overlay,
1754            mesh_clip_inc,
1755            mesh_clip_dec,
1756            projective_layer,
1757        }
1758    }
1759}
1760
1761/// AABB of a rect under the *plain affine* part of a transform (linear +
1762/// translation, no origin re-pivot).
1763fn affine_aabb(transform: &Transform, rect: &repose_core::Rect) -> repose_core::Rect {
1764    let m = transform.linear();
1765    let (tx, ty) = (transform.translate_x, transform.translate_y);
1766    let corners = [
1767        (rect.x, rect.y),
1768        (rect.x + rect.w, rect.y),
1769        (rect.x, rect.y + rect.h),
1770        (rect.x + rect.w, rect.y + rect.h),
1771    ];
1772    let mut min_x = f32::INFINITY;
1773    let mut min_y = f32::INFINITY;
1774    let mut max_x = f32::NEG_INFINITY;
1775    let mut max_y = f32::NEG_INFINITY;
1776    for (x, y) in corners {
1777        let wx = m[0] * x + m[1] * y + tx;
1778        let wy = m[2] * x + m[3] * y + ty;
1779        min_x = min_x.min(wx);
1780        min_y = min_y.min(wy);
1781        max_x = max_x.max(wx);
1782        max_y = max_y.max(wy);
1783    }
1784    repose_core::Rect {
1785        x: min_x,
1786        y: min_y,
1787        w: (max_x - min_x).max(0.0),
1788        h: (max_y - min_y).max(0.0),
1789    }
1790}
1791
1792/// Convert a local-space rect + transform to NDC center-based position+size
1793/// plus the forward rotation/shear 2x2 (row-major `[m00, m01, m10, m11]`,
1794/// scale-free: scale rides in the NDC size). Shaders apply it to quad corners
1795/// and its adjugate/determinant inverse to sample positions.
1796fn rect_to_instance_ndc(
1797    rect: repose_core::Rect,
1798    transform: &Transform,
1799    fb_w: f32,
1800    fb_h: f32,
1801) -> ([f32; 4], [f32; 4]) {
1802    let cx = rect.x + rect.w * 0.5;
1803    let cy = rect.y + rect.h * 0.5;
1804
1805    let m = transform.linear();
1806    let tx = m[0] * cx + m[1] * cy + transform.translate_x;
1807    let ty = m[2] * cx + m[3] * cy + transform.translate_y;
1808
1809    let ndc_cx = (tx / fb_w) * 2.0 - 1.0;
1810    let ndc_cy = 1.0 - (ty / fb_h) * 2.0;
1811    // NDC size (after scale only, no rotation - rotation is done in shader)
1812    let ndc_w = (rect.w * transform.scale_x / fb_w) * 2.0;
1813    let ndc_h = (rect.h * transform.scale_y / fb_h) * 2.0;
1814
1815    ([ndc_cx, ndc_cy, ndc_w, ndc_h], forward_rs_mat(transform))
1816}
1817
1818/// Forward rotation+shear 2x2 (row-major, scale-free) for instance
1819/// attributes. Identity for untransformed content; degenerate shear (only
1820/// from absurd inputs) falls back to identity.
1821fn forward_rs_mat(transform: &Transform) -> [f32; 4] {
1822    let c = transform.rotate.cos();
1823    let s = transform.rotate.sin();
1824    let (hx, hy) = (transform.shear_x, transform.shear_y);
1825    let m = [c - s * hy, c * hx - s, s + c * hy, s * hx + c];
1826    if (m[0] * m[3] - m[1] * m[2]).abs() < 1e-6 {
1827        return [1.0, 0.0, 0.0, 1.0];
1828    }
1829    m
1830}
1831
1832/// A segment of the frame that draws into a single render target.
1833struct Pass {
1834    id: u64,
1835    target: PassTarget,
1836    /// The scissor to apply when the pass is opened.
1837    initial_scissor: (u32, u32, u32, u32),
1838    /// The currently active scissor in the translated pass. `None` means an
1839    /// empty intersection and all draws must be suppressed.
1840    active_scissor: Option<(u32, u32, u32, u32)>,
1841    /// `None` means `LoadOp::Load` (resume existing content);
1842    /// `Some(c)` means `LoadOp::Clear(c)`.
1843    clear_color: Option<[f32; 4]>,
1844    cmds: Vec<Cmd>,
1845}
1846
1847struct LayerState {
1848    layer_id: u32,
1849    parent_target: PassTarget,
1850    parent_scissors: Vec<repose_core::Rect>,
1851    parent_root_clip: repose_core::Rect,
1852    parent_size: (f32, f32),
1853    parent_transform: Transform,
1854    parent_scissor: Option<(u32, u32, u32, u32)>,
1855    parent_clips: Vec<ActiveClip>,
1856    alpha: f32,
1857    blur: (f32, f32),
1858    rectangle_edge: bool,
1859}
1860
1861/// One translator-flattened perspective layer (see
1862/// `push_perspective_layer`): the projective map and everything needed to
1863/// restore the parent target on the matching pop.
1864struct FlattenRecord {
1865    /// `transform_stack.len()` before this flatten pushed its two entries
1866    /// (stripped affine + layer-local shift).
1867    stack_len: usize,
1868    layer_id: u32,
1869    /// Full projective map (row-major 3x3): affine ancestors over the
1870    /// perspective node, in parent-target coordinates.
1871    map: [f32; 9],
1872    /// Layer rect in the parent target's coordinates.
1873    layer_rect: repose_core::Rect,
1874    saved_scissor_stack: Vec<repose_core::Rect>,
1875    saved_root: repose_core::Rect,
1876    saved_size: (f32, f32),
1877    saved_active_scissor: Option<(u32, u32, u32, u32)>,
1878    saved_clips: Vec<ActiveClip>,
1879}
1880
1881/// First translator-owned flatten layer id. Producer ids start at 1 per
1882/// scene, so this range never collides; ids are drained from the layer pool
1883/// after each frame, so reuse across frames is safe.
1884const FLATTEN_ID_BASE: u32 = 0xF000_0000;
1885const MAX_GRAPHICS_LAYERS: usize = 128;
1886const MAX_GRAPHICS_LAYER_BYTES: u64 = 256 * 1024 * 1024;
1887const MAX_TRANSIENT_GRAPHICS_LAYERS: usize = 64;
1888const MAX_TRANSIENT_GRAPHICS_LAYER_BYTES: u64 = 128 * 1024 * 1024;
1889const STENCIL_BASE: u32 = 128;
1890const STENCIL_MAX_DEPTH: u32 = 127;
1891
1892#[allow(non_snake_case)]
1893enum Cmd {
1894    ClipPush {
1895        off: u64,
1896        cnt: u32,
1897        scissor: (u32, u32, u32, u32),
1898        difference: bool,
1899        applied: bool,
1900    },
1901    ClipPop {
1902        off: u64,
1903        cnt: u32,
1904        /// Scissor restored for the draws that follow the pop (the parent clip).
1905        scissor: (u32, u32, u32, u32),
1906        /// Scissor the matching push drew its stencil pass with; the decrement
1907        /// must use it so both passes touch the same pixels.
1908        stencil_scissor: (u32, u32, u32, u32),
1909        difference: bool,
1910        applied: bool,
1911    },
1912    Rect {
1913        off: u64,
1914        cnt: u32,
1915    },
1916    Border {
1917        off: u64,
1918        cnt: u32,
1919    },
1920    Ellipse {
1921        off: u64,
1922        cnt: u32,
1923    },
1924    EllipseBorder {
1925        off: u64,
1926        cnt: u32,
1927    },
1928    Arc {
1929        off: u64,
1930        cnt: u32,
1931    },
1932    GlyphsMask {
1933        off: u64,
1934        cnt: u32,
1935    },
1936    GlyphsColor {
1937        off: u64,
1938        cnt: u32,
1939    },
1940    GlyphsVector {
1941        off: u64,
1942        cnt: u32,
1943    },
1944    ImageRgba {
1945        off: u64,
1946        cnt: u32,
1947        handle: u64,
1948        filter: ImageFilter,
1949        /// Bind to the repeating samplers so `ImageFit::Tile` UVs wrap.
1950        tile: bool,
1951        /// Fixed-function blend mode; `Alpha` uses `image_rgba`.
1952        blend: repose_core::BlendMode,
1953    },
1954    /// Composite a tinted A8 coverage tile (`SceneNode::Coverage`). The
1955    /// instance lives in `self.glyph_color.ring` (a `GlyphInstance`); the
1956    /// bind comes from the coverage registry.
1957    Coverage {
1958        off: u64,
1959        cnt: u32,
1960        handle: u64,
1961    },
1962    ImageNv12 {
1963        off: u64,
1964        cnt: u32,
1965        handle: u64,
1966        filter: ImageFilter,
1967        /// Fixed-function blend mode; `Alpha` uses `image_nv12`.
1968        blend: repose_core::BlendMode,
1969    },
1970    /// Composite a previously-rendered graphics layer back into the
1971    /// current target as a textured quad. The quad's vertex buffer
1972    /// lives in `self.glyph_color.ring` (a `GlyphInstance`).
1973    CompositeLayer {
1974        off: u64,
1975        cnt: u32,
1976        layer_id: u32,
1977    },
1978    /// Separable gaussian blur, alpha channel only: the horizontal pass into a
1979    /// scratch layer, and the vertical pass that tints the result as a drop
1980    /// shadow. `layer_id` names the texture to sample, which is the source
1981    /// layer for the first pass and the scratch for the second. The quad's
1982    /// vertex buffer lives in `self.blur_ring` (a `BlurInstance`).
1983    CompositeShadow {
1984        off: u64,
1985        cnt: u32,
1986        layer_id: u32,
1987    },
1988    /// Separable gaussian blur over full RGBA, for `Modifier::blur`. Two
1989    /// stages exactly like [`Cmd::CompositeShadow`].
1990    CompositeBlur {
1991        off: u64,
1992        cnt: u32,
1993        layer_id: u32,
1994    },
1995    /// Composite a flattened perspective layer through its projective map.
1996    /// The instance lives in `self.projective_ring` (a `ProjectiveInstance`
1997    /// with CPU-projected NDC corners); sampled from the layer's texture
1998    /// with perspective-correct UVs by the `projective_layer` pipeline.
1999    CompositeProjective {
2000        off: u64,
2001        cnt: u32,
2002        layer_id: u32,
2003    },
2004    /// Draw a tessellated vector mesh (solid or gradient paint).
2005    VectorMesh {
2006        mesh: MeshGpu,
2007        uoff: u64,
2008        blend: repose_core::BlendMode,
2009    },
2010    /// Composite an isolated source layer over the current target with a
2011    /// backdrop-dependent CSS blend mode. The shader samples both textures
2012    /// and composites source-over by hand, so the pass runs REPLACE.
2013    /// `parent` records which target the composite draws into (surface or
2014    /// layer); the instance NDC is mapped against it, so any parent origin
2015    /// works.
2016    BlendLayer {
2017        off: u64,
2018        cnt: u32,
2019        src_layer: u32,
2020        dst_layer: Option<u32>,
2021        parent: PassTarget,
2022        scissor: (u32, u32, u32, u32),
2023    },
2024    /// Draw a screen-space overlay mesh (identity transform, device pixels).
2025    VectorOverlay {
2026        mesh: MeshGpu,
2027        uoff: u64,
2028    },
2029    /// Increment the stencil buffer with a tessellated vector mask.
2030    /// `difference` marks an inverse (`\iclip`-style) mask: content draws
2031    /// *outside* it. The counting still balances (push increments, pop
2032    /// decrements); only the depth bookkeeping differs (see executor).
2033    VectorClipPush {
2034        mesh: MeshGpu,
2035        uoff: u64,
2036        scissor: (u32, u32, u32, u32),
2037        difference: bool,
2038        applied: bool,
2039    },
2040    /// Decrement the stencil buffer with the matching vector mask.
2041    VectorClipPop {
2042        mesh: MeshGpu,
2043        uoff: u64,
2044        scissor: (u32, u32, u32, u32),
2045        stencil_scissor: (u32, u32, u32, u32),
2046        difference: bool,
2047        applied: bool,
2048    },
2049    Callback {
2050        rect: repose_core::Rect,
2051        clip_rect: repose_core::Rect,
2052        scissor: (u32, u32, u32, u32),
2053        restore_scissor: (u32, u32, u32, u32),
2054        callback_id: usize,
2055        payload: repose_core::PaintCallbackPayload,
2056    },
2057}
2058
2059/// A registered A8 coverage tile: single-channel mask sampled as coverage
2060/// by `SceneNode::Coverage`. Tiles are immutable; producers re-register on
2061/// geometry change and `remove_coverage` stale handles (unused tiles also
2062/// age out via the image eviction policy).
2063struct CoverageTex {
2064    // Held to keep the GPU texture alive (freed on remove/evict).
2065    #[allow(dead_code)]
2066    tex: wgpu::Texture,
2067    bind: wgpu::BindGroup,
2068    w: u32,
2069    h: u32,
2070    last_used_frame: u64,
2071    bytes: u64,
2072}
2073
2074struct ImageBinds {
2075    linear: wgpu::BindGroup,
2076    nearest: wgpu::BindGroup,
2077    /// `ImageFit::Tile` variants, bound to repeating samplers.
2078    tile_linear: wgpu::BindGroup,
2079    tile_nearest: wgpu::BindGroup,
2080}
2081
2082impl ImageBinds {
2083    fn get(&self, filter: ImageFilter, tile: bool) -> &wgpu::BindGroup {
2084        match (filter, tile) {
2085            (ImageFilter::Linear, false) => &self.linear,
2086            (ImageFilter::Nearest, false) => &self.nearest,
2087            (ImageFilter::Linear, true) => &self.tile_linear,
2088            (ImageFilter::Nearest, true) => &self.tile_nearest,
2089        }
2090    }
2091}
2092
2093enum ImageTex {
2094    Rgba {
2095        tex: wgpu::Texture,
2096        binds: ImageBinds,
2097        w: u32,
2098        h: u32,
2099        format: wgpu::TextureFormat,
2100        last_used_frame: u64,
2101        bytes: u64,
2102    },
2103    /// For a user-provided texture view.
2104    User {
2105        binds: ImageBinds,
2106        w: u32,
2107        h: u32,
2108        last_used_frame: u64,
2109        bytes: u64,
2110    },
2111    Nv12 {
2112        tex_y: wgpu::Texture,
2113        tex_uv: wgpu::Texture,
2114        binds: ImageBinds,
2115        yuv_buf: wgpu::Buffer,
2116        w: u32,
2117        h: u32,
2118        pixel_format: PixelFormat,
2119        fourcc: Option<u32>,
2120        color_info: ColorInfo,
2121        external: bool,
2122        last_used_frame: u64,
2123        bytes: u64,
2124    },
2125}
2126
2127impl ImageTex {
2128    fn evictable(&self) -> bool {
2129        match self {
2130            Self::Rgba { .. } => true,
2131            Self::User { .. } => false,
2132            Self::Nv12 { external, .. } => !*external,
2133        }
2134    }
2135}
2136
2137#[derive(Clone)]
2138struct RetainedImage {
2139    w: u32,
2140    h: u32,
2141    format: wgpu::TextureFormat,
2142    rgba: Vec<u8>,
2143    last_used_frame: u64,
2144}
2145
2146struct ResolvedImageSource {
2147    width: f32,
2148    height: f32,
2149    uv_min: [f32; 2],
2150    uv_max: [f32; 2],
2151}
2152
2153fn resolve_image_source(
2154    source_rect: Option<ImageSourceRect>,
2155    image_width: u32,
2156    image_height: u32,
2157) -> Option<ResolvedImageSource> {
2158    if image_width == 0 || image_height == 0 {
2159        return None;
2160    }
2161    let Some(source) = source_rect else {
2162        return Some(ResolvedImageSource {
2163            width: image_width as f32,
2164            height: image_height as f32,
2165            uv_min: [0.0, 0.0],
2166            uv_max: [1.0, 1.0],
2167        });
2168    };
2169    if source.is_empty() {
2170        return None;
2171    }
2172    let right = source.x.checked_add(source.width)?;
2173    let bottom = source.y.checked_add(source.height)?;
2174    if right > image_width || bottom > image_height {
2175        return None;
2176    }
2177    Some(ResolvedImageSource {
2178        width: source.width as f32,
2179        height: source.height as f32,
2180        uv_min: [
2181            source.x as f32 / image_width as f32,
2182            source.y as f32 / image_height as f32,
2183        ],
2184        uv_max: [
2185            right as f32 / image_width as f32,
2186            bottom as f32 / image_height as f32,
2187        ],
2188    })
2189}
2190
2191/// Draw rect and UV range for `fit` inside `rect`.
2192///
2193/// Mirrors Compose's `PainterNode.draw`: scale the source by
2194/// `ContentScale.computeScaleFactor`, align the result within the bounds, then
2195/// clip to those bounds. Clipping remaps the UVs rather than pushing a clip
2196/// rect, matching what Compose's `clipToBounds` and Godot's `clip_contents`
2197/// produce visually without the extra pass. Modes that scale past an edge
2198/// (`Cover`, `FitWidth`, `FitHeight`, `None`) rely on it to stay in bounds.
2199fn image_fit_geometry(
2200    rect: repose_core::Rect,
2201    fit: ImageFit,
2202    alignment: ImageAlignment,
2203    source: &ResolvedImageSource,
2204) -> Option<(repose_core::Rect, [f32; 4])> {
2205    let src_w = source.width;
2206    let src_h = source.height;
2207    let dst_w = rect.w.max(0.0);
2208    let dst_h = rect.h.max(0.0);
2209    if src_w <= 0.0 || src_h <= 0.0 || dst_w <= 0.0 || dst_h <= 0.0 {
2210        return None;
2211    }
2212
2213    // Slack placement inside the destination rect: `alignment` picks which
2214    // corner the fitted image hugs (CSS `object-position`).
2215    let slack = |content: f32, extent: f32| match alignment {
2216        ImageAlignment::Center => (extent - content) * 0.5,
2217        ImageAlignment::Begin => 0.0,
2218        ImageAlignment::End => extent - content,
2219    };
2220
2221    // A tiled draw covers the bounds at native resolution, so its UVs run past
2222    // 1.0 and the sampler wraps (see address-mode selection at draw time).
2223    let tiled = matches!(fit, ImageFit::Tile);
2224    let (scale_x, scale_y) = fit.scale_factor((src_w, src_h), (dst_w, dst_h));
2225    let drawn = if tiled {
2226        repose_core::Rect {
2227            x: rect.x,
2228            y: rect.y,
2229            w: dst_w,
2230            h: dst_h,
2231        }
2232    } else {
2233        let (w, h) = (src_w * scale_x, src_h * scale_y);
2234        repose_core::Rect {
2235            x: rect.x + slack(w, dst_w),
2236            y: rect.y + slack(h, dst_h),
2237            w,
2238            h,
2239        }
2240    };
2241
2242    let uv_min = source.uv_min;
2243    let uv_max = source.uv_max;
2244    let repeats = if tiled {
2245        [dst_w / src_w, dst_h / src_h]
2246    } else {
2247        [1.0, 1.0]
2248    };
2249    let mut uv = [
2250        uv_min[0],
2251        uv_max[1],
2252        uv_min[0] + (uv_max[0] - uv_min[0]) * repeats[0],
2253        uv_max[1] - (uv_max[1] - uv_min[1]) * repeats[1],
2254    ];
2255
2256    let clipped = intersect(drawn, rect);
2257    if clipped.w <= 0.0 || clipped.h <= 0.0 {
2258        return None;
2259    }
2260    let du = uv[2] - uv[0];
2261    let dv = uv[3] - uv[1];
2262    uv = [
2263        uv[0] + du * (clipped.x - drawn.x) / drawn.w,
2264        uv[1] + dv * (clipped.y - drawn.y) / drawn.h,
2265        uv[0] + du * (clipped.x + clipped.w - drawn.x) / drawn.w,
2266        uv[1] + dv * (clipped.y + clipped.h - drawn.y) / drawn.h,
2267    ];
2268    Some((clipped, uv))
2269}
2270
2271struct AtlasA8 {
2272    tex: wgpu::Texture,
2273    view: wgpu::TextureView,
2274    sampler: wgpu::Sampler,
2275    size: u32,
2276    next_x: u32,
2277    next_y: u32,
2278    row_h: u32,
2279    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2280    /// Summed `w * h` of every live entry. The pixel budget is what actually
2281    /// binds a shelf packer, so eviction trims on area, not entry count.
2282    used_area: u64,
2283}
2284
2285struct AtlasRGBA {
2286    tex: wgpu::Texture,
2287    view: wgpu::TextureView,
2288    sampler: wgpu::Sampler,
2289    size: u32,
2290    next_x: u32,
2291    next_y: u32,
2292    row_h: u32,
2293    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2294    used_area: u64,
2295}
2296
2297/// Work an atlas could not do yet, applied at the next frame boundary. Both
2298/// growth and compaction re-pack the atlas, which would invalidate UVs already
2299/// batched into the frame in flight, so neither may run mid-frame.
2300#[derive(Default, Clone, Copy)]
2301struct AtlasPending {
2302    /// A cell did not fit and the atlas is still below its size cap.
2303    grow: bool,
2304    /// The atlas is at its cap, so the only remedy left is dropping cold
2305    /// entries.
2306    compact: bool,
2307}
2308
2309#[derive(Clone, Copy)]
2310struct GlyphInfo {
2311    u0: f32,
2312    v0: f32,
2313    u1: f32,
2314    v1: f32,
2315    w: f32,
2316    h: f32,
2317    /// `cache_touch_clock` at the last request, and the frame this glyph was
2318    /// last batched into. Entries in flight are never evicted: the instance
2319    /// buffer already holds their UVs, and a repack would move them.
2320    last_touch: u64,
2321    last_used_frame: u64,
2322}
2323
2324#[derive(Clone, Hash, PartialEq, Eq)]
2325struct TextShapeKey {
2326    text: Arc<str>,
2327    px_bits: u32,
2328    line_height_bits: u32,
2329    font_family: Option<&'static str>,
2330    font_weight: u16,
2331    font_style: u8,
2332    letter_spacing_bits: u32,
2333    variation: Option<Arc<str>>,
2334    font_generation: u64,
2335}
2336
2337struct TextShapeCacheEntry {
2338    glyphs: Arc<[repose_text::ShapedGlyph]>,
2339    last_used_frame: u64,
2340}
2341
2342/// Instance data for one shape. The brush enums share `bits` rather than
2343/// taking a varying each, and `grad_p0`/`grad_p1` merge into one `grad`
2344/// vector, to keep the fragment stage inside the varying budget that
2345/// `field_offset!` in `Pipelines::create` derives the attribute table from.
2346#[repr(C)]
2347#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2348struct RectInstance {
2349    xywh: [f32; 4],
2350    radii: [f32; 4],
2351    color0: [f32; 4],
2352    color1: [f32; 4],
2353    fwd_mat: [f32; 4],
2354    grad: [f32; 4],
2355    bits: u32,
2356    _pad: [f32; 3],
2357}
2358
2359#[repr(C)]
2360#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2361struct BorderInstance {
2362    xywh: [f32; 4],
2363    radii: [f32; 4],
2364    color0: [f32; 4],
2365    color1: [f32; 4],
2366    fwd_mat: [f32; 4],
2367    grad: [f32; 4],
2368    bits: u32,
2369    stroke: f32,
2370    _pad: [f32; 2],
2371}
2372
2373#[repr(C)]
2374#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2375struct EllipseInstance {
2376    xywh: [f32; 4],
2377    color0: [f32; 4],
2378    color1: [f32; 4],
2379    fwd_mat: [f32; 4],
2380    grad: [f32; 4],
2381    bits: u32,
2382    _pad: [f32; 1],
2383}
2384
2385#[repr(C)]
2386#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2387struct EllipseBorderInstance {
2388    xywh: [f32; 4],
2389    color0: [f32; 4],
2390    color1: [f32; 4],
2391    fwd_mat: [f32; 4],
2392    grad: [f32; 4],
2393    bits: u32,
2394    stroke: f32,
2395    pad: f32,
2396    _pad: f32,
2397}
2398
2399#[repr(C)]
2400#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2401struct ArcInstance {
2402    xywh: [f32; 4],
2403    color0: [f32; 4],
2404    color1: [f32; 4],
2405    fwd_mat: [f32; 4],
2406    grad: [f32; 4],
2407    bits: u32,
2408    start_angle: f32,
2409    sweep_angle: f32,
2410    stroke: f32,
2411    pad: f32,
2412    _pad: f32,
2413}
2414
2415#[repr(C)]
2416#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2417struct GlyphInstance {
2418    xywh: [f32; 4],
2419    uv: [f32; 4],
2420    color: [f32; 4],
2421    fwd_mat: [f32; 4],
2422}
2423
2424#[repr(C)]
2425#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2426struct BlurInstance {
2427    xywh: [f32; 4],
2428    uv: [f32; 4],
2429    color: [f32; 4],
2430    sigma_px: [f32; 2],
2431    fwd_mat: [f32; 4],
2432    axis: u32,
2433    edge_mode: u32,
2434    /// Corner radii, in source texels, shaping the blurred silhouette. All zero
2435    /// leaves it unshaped. Read only by the source-reading axis; the axis that
2436    /// samples the scratch must leave this zero.
2437    shape: [f32; 4],
2438}
2439
2440/// Projective layer-composite instance: the four layer-rect corners projected
2441/// to NDC (`c0..c3`, counter-clockwise from top-left) with their homogeneous
2442/// `w`, the layer-texture uv bounds, and a group alpha. Matches
2443/// `projective_layer.wgsl` (offsets: c0@0 c1@8 c2@16 c3@24 uv@32 w@48
2444/// alpha@64; stride 80).
2445#[repr(C)]
2446#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2447struct ProjectiveInstance {
2448    c0: [f32; 2],
2449    c1: [f32; 2],
2450    c2: [f32; 2],
2451    c3: [f32; 2],
2452    uv: [f32; 4],
2453    w: [f32; 4],
2454    alpha: f32,
2455    _pad: [f32; 3],
2456}
2457
2458/// CPU-computed Y′CbCr -> R′G′B′ transform uploaded as a uniform buffer.
2459/// Layout matches the WGSL `YuvTransform` struct (5 × vec4<f32>).
2460#[repr(C)]
2461#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2462struct YuvTransformRaw {
2463    row0: [f32; 4],
2464    row1: [f32; 4],
2465    row2: [f32; 4],
2466    b: [f32; 4],
2467    transfer: [f32; 4],
2468}
2469
2470fn make_yuv_transform_raw(color_info: ColorInfo, pixel_format: PixelFormat) -> YuvTransformRaw {
2471    let yuv = color_info.to_yuv_transform();
2472    let sample_scale = match pixel_format {
2473        PixelFormat::P010 => 65535.0 / (64.0 * 1020.0),
2474        _ => 1.0,
2475    };
2476    let transfer_mode = match color_info.transfer {
2477        repose_core::color::Transfer::Srgb => 0.0,
2478        repose_core::color::Transfer::Bt709 => 1.0,
2479        repose_core::color::Transfer::Linear => 2.0,
2480        repose_core::color::Transfer::Pq => 3.0,
2481        repose_core::color::Transfer::Hlg => 4.0,
2482    };
2483    YuvTransformRaw {
2484        row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2485        row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2486        row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2487        b: [yuv.b[0], yuv.b[1], yuv.b[2], sample_scale],
2488        transfer: [transfer_mode, 0.0, 0.0, 0.0],
2489    }
2490}
2491
2492#[repr(C)]
2493#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2494struct Nv12Instance {
2495    xywh: [f32; 4],
2496    uv: [f32; 4],
2497    color: [f32; 4],
2498    uv_x_offset: f32,
2499    uv_y_offset: f32,
2500    fwd_mat: [f32; 4],
2501}
2502
2503#[repr(C)]
2504#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2505struct ClipInstance {
2506    xywh: [f32; 4],
2507    radii: [f32; 4],
2508    fwd_mat: [f32; 4],
2509}
2510
2511/// Backdrop-blend composite instance: a `GlyphInstance`-shaped quad plus
2512/// the `BlendMode` discriminant consumed by `blend_layer.wgsl`.
2513#[repr(C)]
2514#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2515struct BlendInstance {
2516    xywh: [f32; 4],
2517    uv: [f32; 4],
2518    color: [f32; 4],
2519    fwd_mat: [f32; 4],
2520    mode: u32,
2521    _pad: [f32; 3],
2522}
2523
2524#[repr(C)]
2525#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2526struct MeshVertex {
2527    pos: [f32; 2],
2528    color: [f32; 4],
2529    uv: [f32; 2],
2530}
2531
2532#[derive(Clone)]
2533struct MeshGpu {
2534    vertices: wgpu::Buffer,
2535    indices: wgpu::Buffer,
2536    vertex_count: u32,
2537    index_count: u32,
2538    bytes: u64,
2539}
2540
2541#[derive(Clone, Copy, PartialEq, Eq, Hash)]
2542struct MeshCacheKey {
2543    source: usize,
2544    index: usize,
2545    kind: u8,
2546    vertex_len: usize,
2547    index_len: usize,
2548}
2549
2550#[derive(Clone)]
2551enum MeshOwner {
2552    Object(Arc<repose_core::VectorMeshData>),
2553    Overlay(Arc<[repose_core::VectorMeshData]>),
2554}
2555
2556struct MeshCacheEntry {
2557    owner: MeshOwner,
2558    gpu: MeshGpu,
2559    last_used_frame: u64,
2560    last_touch_frame: u64,
2561}
2562
2563#[derive(Clone, PartialEq, Eq, Hash)]
2564struct SlugDrawKey {
2565    cache_key: repose_text::CacheKey,
2566    fill: bool,
2567    /// The stroke parameters themselves, not a digest of them: a hash
2568    /// collision here would serve another stroke's tessellation.
2569    stroke: Option<slug::StrokeTessKey>,
2570    transform: [u32; 11],
2571    origin: [u32; 2],
2572    px_bits: u32,
2573    target: [u32; 2],
2574    color: [u32; 4],
2575}
2576
2577struct SlugDrawCacheEntry {
2578    vertices: Arc<[slug::TessVertex]>,
2579    bytes: u64,
2580    last_used_frame: u64,
2581    last_touch_frame: u64,
2582}
2583
2584/// Vector outline for one glyph, cached so repeated text at the same size does
2585/// not re-run outline extraction. `commands` is `None` for glyphs the extractor
2586/// rejects; that negative result is cached too, so a bad glyph is not retried.
2587struct GlyphOutlineEntry {
2588    commands: Option<Arc<[repose_text::Command]>>,
2589    last_touch: u64,
2590}
2591
2592/// Drop least-recently-used atlas entries until the live set fits `target_area`
2593/// pixels, skipping glyphs batched into the frame in flight. Never empties the
2594/// atlas, so a repack always has something left to pack around. Returns false
2595/// when nothing is evictable.
2596fn evict_atlas_lru(
2597    map: &mut HashMap<repose_text::CacheKey, GlyphInfo>,
2598    used_area: &mut u64,
2599    frame_index: u64,
2600    target_area: u64,
2601) -> bool {
2602    let mut victims: Vec<(u64, repose_text::CacheKey)> = map
2603        .iter()
2604        .filter(|(_, info)| info.last_used_frame != frame_index)
2605        .map(|(key, info)| (info.last_touch, key.clone()))
2606        .collect();
2607    if victims.is_empty() {
2608        return false;
2609    }
2610    // Ties are equally cold, so their relative order does not matter.
2611    victims.sort_unstable_by_key(|victim| victim.0);
2612    let mut evicted = false;
2613    for (_, key) in victims {
2614        if *used_area <= target_area || map.len() <= 1 {
2615            break;
2616        }
2617        if let Some(info) = map.remove(&key) {
2618            *used_area = used_area.saturating_sub(glyph_cell_area(&info));
2619            evicted = true;
2620        }
2621    }
2622    evicted
2623}
2624
2625/// Pixels a packed cell occupies. `w`/`h` are exact small integers by the time
2626/// they reach `GlyphInfo`, so the round trip through `f32` is lossless.
2627fn glyph_cell_area(info: &GlyphInfo) -> u64 {
2628    (info.w as u64).saturating_mul(info.h as u64)
2629}
2630
2631/// Remember that a glyph had no room, so it is skipped instead of re-rasterized
2632/// every frame until the next repack. Capped like the failure sets; dropping
2633/// the oldest entries only costs an earlier retry.
2634fn defer_atlas_glyph(deferred: &mut HashSet<repose_text::CacheKey>, key: repose_text::CacheKey) {
2635    if deferred.len() >= MAX_GLYPH_ATLAS_ENTRIES {
2636        deferred.clear();
2637    }
2638    deferred.insert(key);
2639}
2640
2641/// What one slug draw cache entry really costs: the vertex array plus the key,
2642/// the entry record and the map slot holding it. Charging only the array makes
2643/// the byte budget silently optimistic.
2644fn slug_draw_cache_entry_bytes(vertices: usize) -> u64 {
2645    let per_entry = (std::mem::size_of::<SlugDrawKey>()
2646        + std::mem::size_of::<SlugDrawCacheEntry>()
2647        + std::mem::size_of::<(SlugDrawKey, SlugDrawCacheEntry)>()) as u64;
2648    u64::try_from(vertices)
2649        .ok()
2650        .and_then(|count| count.checked_mul(std::mem::size_of::<slug::TessVertex>() as u64))
2651        .and_then(|payload| payload.checked_add(per_entry))
2652        .unwrap_or(u64::MAX)
2653}
2654
2655/// Per-draw vector mesh uniform. Field order and every member width are
2656/// load-bearing: the WGSL `MeshUniform` in `shaders/mesh.wgsl` must match it
2657/// member for member, and uniform address space forces 16-byte alignment, so
2658/// the two-point gradient endpoints are padded out to `vec4`s and the stop
2659/// table is one `vec4` per colour plus four offsets per `vec4` (never a bare
2660/// `array<f32>`). `paint.z` carries the live stop count (0..=8).
2661#[repr(C)]
2662#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2663struct MeshUniform {
2664    m0: [f32; 4],
2665    m1: [f32; 4],
2666    paint: [u32; 4],
2667    color0: [f32; 4],
2668    color1: [f32; 4],
2669    grad_start: [f32; 2],
2670    _p3: [f32; 2],
2671    grad_end: [f32; 2],
2672    _p4: [f32; 2],
2673    stop_colors: [[f32; 4]; MAX_GRADIENT_STOPS],
2674    stop_offsets: [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2675}
2676
2677/// Bytes `MeshUniform` occupies. `tests::mesh_uniform_matches_the_wgsl_member_layout`
2678/// parses `shaders/mesh.wgsl` and checks every member offset and the total
2679/// size against the struct, so the two halves cannot drift apart.
2680const MESH_UNIFORM_BYTES: usize = 272;
2681const _: () = assert!(std::mem::size_of::<MeshUniform>() == MESH_UNIFORM_BYTES);
2682
2683const MESH_UNIFORM_CAP: u64 = 4 * 1024 * 1024;
2684const MAX_RETAINED_IMAGE_BYTES: u64 = 256 * 1024 * 1024;
2685const MAX_RETAINED_IMAGES: usize = 512;
2686const MAX_BLEND_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
2687const MAX_BLEND_SNAPSHOT_POOL_ENTRIES: usize = 64;
2688const MAX_GLYPH_ATLAS_ENTRIES: usize = 32768;
2689/// Hard ceiling on atlas edge length. Past this the atlas is compacted instead
2690/// of grown, dropping least-recently-used glyphs.
2691const MAX_GLYPH_ATLAS_SIZE: u32 = 4096;
2692/// Fraction of the live set a compaction keeps. Trimming to a fraction of the
2693/// current area (rather than an absolute budget) guarantees a repack always
2694/// reclaims shelf space, even when the atlas is only a few large glyphs deep.
2695const ATLAS_COMPACT_KEEP_NUM: u64 = 3;
2696const ATLAS_COMPACT_KEEP_DEN: u64 = 4;
2697/// Minimum frames between compactions. A working set that genuinely exceeds
2698/// atlas capacity then costs a few missing glyphs per interval instead of
2699/// re-uploading the whole atlas every frame.
2700const ATLAS_COMPACT_MIN_FRAME_INTERVAL: u64 = 30;
2701const MAX_GPU_RESOURCE_BYTES: u64 = 1024 * 1024 * 1024;
2702const MAX_UPLOAD_RING_BYTES: u64 = 64 * 1024 * 1024;
2703const MAX_MESH_CACHE_BYTES: u64 = 256 * 1024 * 1024;
2704const MAX_MESH_CACHE_ENTRIES: usize = 512;
2705const MAX_SLUG_DRAW_CACHE_ENTRIES: usize = 2048;
2706const MAX_SLUG_DRAW_CACHE_BYTES: u64 = 32 * 1024 * 1024;
2707const MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES: usize = 256;
2708const MAX_GLYPH_OUTLINE_CACHE_ENTRIES: usize = 4096;
2709const MAX_IMAGE_DECODE_BYTES: u64 = 64 * 1024 * 1024;
2710const MAX_CALLBACK_SCOPES: usize = 256;
2711const NV12_FOURCC: u32 = 0x3231_564e;
2712const P010_FOURCC: u32 = 0x3031_3050;
2713
2714fn canonical_fourcc(format: PixelFormat) -> u32 {
2715    match format {
2716        PixelFormat::P010 => P010_FOURCC,
2717        _ => NV12_FOURCC,
2718    }
2719}
2720
2721impl MeshUniform {
2722    fn identity() -> Self {
2723        Self {
2724            m0: [1.0, 0.0, 0.0, 0.0],
2725            m1: [0.0, 1.0, 0.0, 0.0],
2726            paint: [0; 4],
2727            color0: [0.0; 4],
2728            color1: [0.0; 4],
2729            grad_start: [0.0; 2],
2730            _p3: [0.0; 2],
2731            grad_end: [0.0; 2],
2732            _p4: [0.0; 2],
2733            stop_colors: [[0.0; 4]; MAX_GRADIENT_STOPS],
2734            stop_offsets: [[0.0; 4]; MAX_GRADIENT_STOPS / 4],
2735        }
2736    }
2737}
2738
2739/// Colours, packed offsets and the live count, ready for `MeshUniform`.
2740type PackedStops = (
2741    [[f32; 4]; MAX_GRADIENT_STOPS],
2742    [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2743    u32,
2744);
2745
2746/// Packs a stop table for the uniform: one `vec4` per colour, four offsets per
2747/// `vec4`, plus the live count (0..=8). Offsets are clamped to `0..=1` and
2748/// forced non-decreasing so the shader can scan them in order without sorting;
2749/// a NaN offset folds to the previous stop's (`f32::max` drops NaN). Order is
2750/// otherwise preserved, so repeated offsets stay a hard stop at that position.
2751fn pack_gradient_stops(stops: &GradientStops) -> PackedStops {
2752    let mut stop_colors = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
2753    let mut flat = [0.0f32; MAX_GRADIENT_STOPS];
2754    let mut count = 0u32;
2755    let mut prev = 0.0f32;
2756    for ((color, offset), stop) in stop_colors
2757        .iter_mut()
2758        .zip(flat.iter_mut())
2759        .zip(stops.as_slice())
2760    {
2761        let clamped = stop.offset.clamp(0.0, 1.0).max(prev);
2762        prev = clamped;
2763        *color = stop.color.to_linear();
2764        *offset = clamped;
2765        count += 1;
2766    }
2767    let mut stop_offsets = [[0.0f32; 4]; MAX_GRADIENT_STOPS / 4];
2768    for (dst, src) in stop_offsets.iter_mut().zip(flat.as_chunks::<4>().0) {
2769        *dst = *src;
2770    }
2771    (stop_colors, stop_offsets, count)
2772}
2773
2774fn mesh_uniform_from_paint(affine: [f32; 6], paint: &repose_core::PaintDesc) -> MeshUniform {
2775    let no_stops = GradientStops::default();
2776    let (paint_type, paint_kind, stops, color0, color1, grad_start, grad_end) = match paint {
2777        repose_core::PaintDesc::Solid => {
2778            (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2])
2779        }
2780        repose_core::PaintDesc::Linear {
2781            start,
2782            end,
2783            start_color,
2784            end_color,
2785        } => (
2786            1u32,
2787            0u32,
2788            no_stops,
2789            start_color.to_linear(),
2790            end_color.to_linear(),
2791            [start.x, start.y],
2792            [end.x, end.y],
2793        ),
2794        repose_core::PaintDesc::Radial {
2795            center,
2796            radius,
2797            start_color,
2798            end_color,
2799        } => (
2800            1u32,
2801            1u32,
2802            no_stops,
2803            start_color.to_linear(),
2804            end_color.to_linear(),
2805            [center.x, center.y],
2806            [radius.max(0.0), 0.0],
2807        ),
2808        repose_core::PaintDesc::Sweep {
2809            center,
2810            start_color,
2811            end_color,
2812        } => (
2813            1u32,
2814            2u32,
2815            no_stops,
2816            start_color.to_linear(),
2817            end_color.to_linear(),
2818            [center.x, center.y],
2819            [0.0, 0.0],
2820        ),
2821        repose_core::PaintDesc::LinearStops { start, end, stops } => (
2822            1u32,
2823            3u32,
2824            *stops,
2825            [0.0; 4],
2826            [0.0; 4],
2827            [start.x, start.y],
2828            [end.x, end.y],
2829        ),
2830        repose_core::PaintDesc::RadialStops {
2831            center,
2832            radius,
2833            stops,
2834        } => (
2835            1u32,
2836            4u32,
2837            *stops,
2838            [0.0; 4],
2839            [0.0; 4],
2840            [center.x, center.y],
2841            [radius.max(0.0), 0.0],
2842        ),
2843        _ => (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
2844    };
2845    let (stop_colors, stop_offsets, count) = pack_gradient_stops(&stops);
2846    MeshUniform {
2847        m0: [affine[0], affine[1], affine[2], 0.0],
2848        m1: [affine[3], affine[4], affine[5], 0.0],
2849        paint: [paint_type, paint_kind, count, 0],
2850        color0,
2851        color1,
2852        grad_start,
2853        _p3: [0.0; 2],
2854        grad_end,
2855        _p4: [0.0; 2],
2856        stop_colors,
2857        stop_offsets,
2858    }
2859}
2860
2861fn combine_mesh_affine(current: &Transform, mesh: [f32; 6]) -> [f32; 6] {
2862    let cm = current.linear();
2863    let (cm00, cm01, cm10, cm11) = (cm[0], cm[1], cm[2], cm[3]);
2864    let mm00 = mesh[0];
2865    let mm01 = mesh[1];
2866    let mm10 = mesh[2];
2867    let mm11 = mesh[3];
2868    let mtx = mesh[4];
2869    let mty = mesh[5];
2870    let r00 = cm00 * mm00 + cm01 * mm10;
2871    let r01 = cm00 * mm01 + cm01 * mm11;
2872    let r10 = cm10 * mm00 + cm11 * mm10;
2873    let r11 = cm10 * mm01 + cm11 * mm11;
2874    let tx = cm00 * mtx + cm01 * mty + current.translate_x;
2875    let ty = cm10 * mtx + cm11 * mty + current.translate_y;
2876    // Canonical slot order consumed by `MeshUniform`/shader and `mesh_aabb`:
2877    // [A, B, tx, C, D, ty] where world = [[A,B],[C,D]] * local + (tx, ty).
2878    [r00, r01, tx, r10, r11, ty]
2879}
2880
2881fn mesh_aabb(mesh: &repose_core::VectorMeshData, affine: [f32; 6]) -> repose_core::Rect {
2882    let mut min_x = f32::INFINITY;
2883    let mut min_y = f32::INFINITY;
2884    let mut max_x = f32::NEG_INFINITY;
2885    let mut max_y = f32::NEG_INFINITY;
2886    for v in mesh.vertices.iter() {
2887        let x = affine[0] * v.pos[0] + affine[1] * v.pos[1] + affine[2];
2888        let y = affine[3] * v.pos[0] + affine[4] * v.pos[1] + affine[5];
2889        min_x = min_x.min(x);
2890        min_y = min_y.min(y);
2891        max_x = max_x.max(x);
2892        max_y = max_y.max(y);
2893    }
2894    let w = (max_x - min_x).max(0.0);
2895    let h = (max_y - min_y).max(0.0);
2896    if !min_x.is_finite() || !min_y.is_finite() {
2897        return repose_core::Rect {
2898            x: 0.0,
2899            y: 0.0,
2900            w: 0.0,
2901            h: 0.0,
2902        };
2903    }
2904    repose_core::Rect {
2905        x: min_x,
2906        y: min_y,
2907        w,
2908        h,
2909    }
2910}
2911
2912fn quantize_glyph_px(px: f32) -> f32 {
2913    if !px.is_finite() || px <= 0.0 {
2914        return 1.0;
2915    }
2916    ((px * 4.0).round() / 4.0).max(0.25)
2917}
2918
2919fn swash_to_a8_coverage(content: repose_text::SwashContent, data: &[u8]) -> Option<Vec<u8>> {
2920    match content {
2921        repose_text::SwashContent::Mask => Some(data.to_vec()),
2922        repose_text::SwashContent::SubpixelMask => {
2923            let mut out = Vec::with_capacity(data.len() / 4);
2924            for px in data.as_chunks::<4>().0 {
2925                let r = px[0];
2926                let g = px[1];
2927                let b = px[2];
2928                out.push(r.max(g).max(b));
2929            }
2930            Some(out)
2931        }
2932        repose_text::SwashContent::Color => None,
2933    }
2934}
2935
2936impl WgpuSceneRenderer {
2937    /// Construct a renderer for `output_format` with `msaa_samples` samples.
2938    ///
2939    /// Validates what is checkable without an adapter: `wgpu` only accepts
2940    /// sample counts of 1, 2, 4, 8 or 16, and an unrenderable or
2941    /// unblendable output format fails later as a validation error rather
2942    /// than here. Prefer
2943    /// [`try_from_device_with_adapter`](Self::try_from_device_with_adapter)
2944    /// whenever an adapter is available - it also checks the format's
2945    /// render-attachment, blend and multisample-resolve support and picks a
2946    /// supported sample count.
2947    pub fn from_device(
2948        device: wgpu::Device,
2949        queue: wgpu::Queue,
2950        output_format: wgpu::TextureFormat,
2951        msaa_samples: u32,
2952    ) -> anyhow::Result<Self> {
2953        validate_sample_count("msaa_samples", msaa_samples)?;
2954        Self::from_device_with_working_space_msaa(
2955            device,
2956            queue,
2957            output_format,
2958            msaa_samples,
2959            msaa_samples,
2960        )
2961    }
2962
2963    /// Validated constructor with an adapter: rejects an output format that
2964    /// cannot back the UI pipelines and clamps `requested_msaa` to the
2965    /// largest supported count at or below it.
2966    pub fn try_from_device_with_adapter(
2967        adapter: &wgpu::Adapter,
2968        device: wgpu::Device,
2969        queue: wgpu::Queue,
2970        output_format: wgpu::TextureFormat,
2971        requested_msaa: u32,
2972    ) -> anyhow::Result<Self> {
2973        let msaa = pick_surface_msaa(adapter, output_format, requested_msaa);
2974        validate_render_target_format(adapter, output_format, msaa)?;
2975        let working_space_msaa =
2976            pick_surface_msaa_for_mode(adapter, wgpu::TextureFormat::Rgba16Float, msaa, true);
2977        Self::from_device_with_working_space_msaa(
2978            device,
2979            queue,
2980            output_format,
2981            msaa,
2982            working_space_msaa,
2983        )
2984    }
2985
2986    /// Point the renderer at a surface whose texture format differs from
2987    /// [`Self::output_format`], as a canvas configured with `viewFormats` does.
2988    /// Call this before the first frame.
2989    pub fn with_surface_format(
2990        mut self,
2991        surface_format: wgpu::TextureFormat,
2992    ) -> anyhow::Result<Self> {
2993        if surface_format != self.output_format
2994            && surface_format.add_srgb_suffix() != self.output_format
2995        {
2996            anyhow::bail!(
2997                "surface format {surface_format:?} cannot be reinterpreted as {:?}",
2998                self.output_format
2999            );
3000        }
3001        if self.surface_format != surface_format {
3002            self.surface_format = surface_format;
3003            self.recreate_msaa_and_depth_stencil();
3004        }
3005        Ok(self)
3006    }
3007
3008    pub fn from_device_with_working_space_msaa(
3009        device: wgpu::Device,
3010        queue: wgpu::Queue,
3011        output_format: wgpu::TextureFormat,
3012        msaa_samples: u32,
3013        working_space_msaa_samples: u32,
3014    ) -> anyhow::Result<Self> {
3015        validate_sample_count("msaa_samples", msaa_samples)?;
3016        validate_sample_count("working_space_msaa_samples", working_space_msaa_samples)?;
3017        let msaa_samples = msaa_samples.max(1);
3018        let working_space_msaa_samples = working_space_msaa_samples.max(1);
3019        let mesh_uniform_size = std::mem::size_of::<MeshUniform>() as u64;
3020        let base_mesh_alignment =
3021            u64::from(device.limits().min_uniform_buffer_offset_alignment).max(4);
3022        let mesh_uniform_alignment = mesh_uniform_stride(mesh_uniform_size, base_mesh_alignment);
3023        let mesh_uniform_cap = align_up(
3024            MESH_UNIFORM_CAP
3025                .min(device.limits().max_buffer_size)
3026                .max(mesh_uniform_alignment),
3027            base_mesh_alignment,
3028        )
3029        .unwrap_or(mesh_uniform_alignment);
3030        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3031            label: Some("globals layout"),
3032            entries: &[wgpu::BindGroupLayoutEntry {
3033                binding: 0,
3034                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3035                ty: wgpu::BindingType::Buffer {
3036                    ty: wgpu::BufferBindingType::Uniform,
3037                    has_dynamic_offset: false,
3038                    min_binding_size: None,
3039                },
3040                count: None,
3041            }],
3042        });
3043
3044        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
3045            label: Some("globals buf"),
3046            size: std::mem::size_of::<Globals>() as u64,
3047            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3048            mapped_at_creation: false,
3049        });
3050
3051        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3052            label: Some("globals bind"),
3053            layout: &globals_layout,
3054            entries: &[wgpu::BindGroupEntry {
3055                binding: 0,
3056                resource: globals_buf.as_entire_binding(),
3057            }],
3058        });
3059        let globals_staging_cap =
3060            (64 * std::mem::size_of::<Globals>() as u64).max(std::mem::size_of::<Globals>() as u64);
3061        let globals_staging = device.create_buffer(&wgpu::BufferDescriptor {
3062            label: Some("globals staging"),
3063            size: globals_staging_cap,
3064            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
3065            mapped_at_creation: false,
3066        });
3067
3068        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
3069
3070        let stencil_for_content = wgpu::DepthStencilState {
3071            format: ds_format,
3072            depth_write_enabled: Some(false),
3073            depth_compare: Some(wgpu::CompareFunction::Always),
3074            stencil: wgpu::StencilState {
3075                front: wgpu::StencilFaceState {
3076                    // Equal (not LessEqual): inverse (`Difference`) vector
3077                    // masks work by keeping the depth while incrementing the
3078                    // masked pixels, so content must test exact equality.
3079                    // Outcomes match LessEqual everywhere except pre-existing
3080                    // stencil leaks, which now fail visibly instead of
3081                    // drawing through (unbalanced clips already warn).
3082                    compare: wgpu::CompareFunction::Equal,
3083                    fail_op: wgpu::StencilOperation::Keep,
3084                    depth_fail_op: wgpu::StencilOperation::Keep,
3085                    pass_op: wgpu::StencilOperation::Keep,
3086                },
3087                back: wgpu::StencilFaceState {
3088                    compare: wgpu::CompareFunction::Equal,
3089                    fail_op: wgpu::StencilOperation::Keep,
3090                    depth_fail_op: wgpu::StencilOperation::Keep,
3091                    pass_op: wgpu::StencilOperation::Keep,
3092                },
3093                read_mask: 0xFF,
3094                write_mask: 0x00,
3095            },
3096            bias: wgpu::DepthBiasState::default(),
3097        };
3098
3099        let stencil_for_clip_inc = wgpu::DepthStencilState {
3100            format: ds_format,
3101            depth_write_enabled: Some(false),
3102            depth_compare: Some(wgpu::CompareFunction::Always),
3103            stencil: wgpu::StencilState {
3104                front: wgpu::StencilFaceState {
3105                    compare: wgpu::CompareFunction::Always,
3106                    fail_op: wgpu::StencilOperation::Keep,
3107                    depth_fail_op: wgpu::StencilOperation::Keep,
3108                    pass_op: wgpu::StencilOperation::IncrementClamp,
3109                },
3110                back: wgpu::StencilFaceState {
3111                    compare: wgpu::CompareFunction::Always,
3112                    fail_op: wgpu::StencilOperation::Keep,
3113                    depth_fail_op: wgpu::StencilOperation::Keep,
3114                    pass_op: wgpu::StencilOperation::IncrementClamp,
3115                },
3116                read_mask: 0xFF,
3117                write_mask: 0xFF,
3118            },
3119            bias: wgpu::DepthBiasState::default(),
3120        };
3121
3122        let stencil_for_clip_dec = wgpu::DepthStencilState {
3123            format: ds_format,
3124            depth_write_enabled: Some(false),
3125            depth_compare: Some(wgpu::CompareFunction::Always),
3126            stencil: wgpu::StencilState {
3127                front: wgpu::StencilFaceState {
3128                    compare: wgpu::CompareFunction::Always,
3129                    fail_op: wgpu::StencilOperation::Keep,
3130                    depth_fail_op: wgpu::StencilOperation::Keep,
3131                    pass_op: wgpu::StencilOperation::DecrementClamp,
3132                },
3133                back: wgpu::StencilFaceState {
3134                    compare: wgpu::CompareFunction::Always,
3135                    fail_op: wgpu::StencilOperation::Keep,
3136                    depth_fail_op: wgpu::StencilOperation::Keep,
3137                    pass_op: wgpu::StencilOperation::DecrementClamp,
3138                },
3139                read_mask: 0xFF,
3140                write_mask: 0xFF,
3141            },
3142            bias: wgpu::DepthBiasState::default(),
3143        };
3144
3145        let _multisample_state = wgpu::MultisampleState {
3146            count: msaa_samples,
3147            mask: !0,
3148            alpha_to_coverage_enabled: false,
3149        };
3150
3151        // PIPELINES
3152
3153        // Single shared sampler for images/text
3154        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3155            label: Some("image/text sampler"),
3156            address_mode_u: wgpu::AddressMode::ClampToEdge,
3157            address_mode_v: wgpu::AddressMode::ClampToEdge,
3158            mag_filter: wgpu::FilterMode::Linear,
3159            min_filter: wgpu::FilterMode::Linear,
3160            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3161            ..Default::default()
3162        });
3163        let image_sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3164            label: Some("image nearest sampler"),
3165            address_mode_u: wgpu::AddressMode::ClampToEdge,
3166            address_mode_v: wgpu::AddressMode::ClampToEdge,
3167            mag_filter: wgpu::FilterMode::Nearest,
3168            min_filter: wgpu::FilterMode::Nearest,
3169            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3170            ..Default::default()
3171        });
3172
3173        // `ImageFit::Tile` emits UVs past 1.0 and relies on wrapping, so it
3174        // needs its own samplers rather than the clamped pair above.
3175        let image_sampler_repeat = device.create_sampler(&wgpu::SamplerDescriptor {
3176            label: Some("image repeat sampler"),
3177            address_mode_u: wgpu::AddressMode::Repeat,
3178            address_mode_v: wgpu::AddressMode::Repeat,
3179            mag_filter: wgpu::FilterMode::Linear,
3180            min_filter: wgpu::FilterMode::Linear,
3181            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3182            ..Default::default()
3183        });
3184        let image_sampler_repeat_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3185            label: Some("image repeat nearest sampler"),
3186            address_mode_u: wgpu::AddressMode::Repeat,
3187            address_mode_v: wgpu::AddressMode::Repeat,
3188            mag_filter: wgpu::FilterMode::Nearest,
3189            min_filter: wgpu::FilterMode::Nearest,
3190            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3191            ..Default::default()
3192        });
3193
3194        // linear filtering only blurs them; nearest keeps the blit crisp.
3195        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3196            label: Some("layer nearest sampler"),
3197            address_mode_u: wgpu::AddressMode::ClampToEdge,
3198            address_mode_v: wgpu::AddressMode::ClampToEdge,
3199            mag_filter: wgpu::FilterMode::Nearest,
3200            min_filter: wgpu::FilterMode::Nearest,
3201            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3202            ..Default::default()
3203        });
3204
3205        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
3206        // the sharp 1:1 layer composite.
3207        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
3208            label: Some("layer linear sampler"),
3209            address_mode_u: wgpu::AddressMode::ClampToEdge,
3210            address_mode_v: wgpu::AddressMode::ClampToEdge,
3211            mag_filter: wgpu::FilterMode::Linear,
3212            min_filter: wgpu::FilterMode::Linear,
3213            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3214            ..Default::default()
3215        });
3216
3217        // Layout for Text / RGBA Images (Texture + Sampler)
3218        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3219            label: Some("text/rgba bind layout"),
3220            entries: &[
3221                wgpu::BindGroupLayoutEntry {
3222                    binding: 0,
3223                    visibility: wgpu::ShaderStages::FRAGMENT,
3224                    ty: wgpu::BindingType::Texture {
3225                        multisampled: false,
3226                        view_dimension: wgpu::TextureViewDimension::D2,
3227                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3228                    },
3229                    count: None,
3230                },
3231                wgpu::BindGroupLayoutEntry {
3232                    binding: 1,
3233                    visibility: wgpu::ShaderStages::FRAGMENT,
3234                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3235                    count: None,
3236                },
3237            ],
3238        });
3239        // We reuse this for RGBA images for simplicity, or create a distinct one
3240        let image_bind_layout_rgba = text_bind_layout.clone();
3241
3242        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
3243        let image_bind_layout_nv12 =
3244            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3245                label: Some("image bind layout nv12"),
3246                entries: &[
3247                    // Y plane
3248                    wgpu::BindGroupLayoutEntry {
3249                        binding: 0,
3250                        visibility: wgpu::ShaderStages::FRAGMENT,
3251                        ty: wgpu::BindingType::Texture {
3252                            multisampled: false,
3253                            view_dimension: wgpu::TextureViewDimension::D2,
3254                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3255                        },
3256                        count: None,
3257                    },
3258                    // UV plane
3259                    wgpu::BindGroupLayoutEntry {
3260                        binding: 1,
3261                        visibility: wgpu::ShaderStages::FRAGMENT,
3262                        ty: wgpu::BindingType::Texture {
3263                            multisampled: false,
3264                            view_dimension: wgpu::TextureViewDimension::D2,
3265                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3266                        },
3267                        count: None,
3268                    },
3269                    // Sampler
3270                    wgpu::BindGroupLayoutEntry {
3271                        binding: 2,
3272                        visibility: wgpu::ShaderStages::FRAGMENT,
3273                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3274                        count: None,
3275                    },
3276                    // YUV transform uniform buffer
3277                    wgpu::BindGroupLayoutEntry {
3278                        binding: 3,
3279                        visibility: wgpu::ShaderStages::FRAGMENT,
3280                        ty: wgpu::BindingType::Buffer {
3281                            ty: wgpu::BufferBindingType::Uniform,
3282                            has_dynamic_offset: false,
3283                            min_binding_size: None,
3284                        },
3285                        count: None,
3286                    },
3287                ],
3288            });
3289
3290        // Clipping layout
3291        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3292            label: Some("clip pipeline layout"),
3293            bind_group_layouts: &[Some(&globals_layout)],
3294            immediate_size: 0,
3295        });
3296        let clip_vertex_layout = wgpu::VertexBufferLayout {
3297            array_stride: std::mem::size_of::<ClipInstance>() as u64,
3298            step_mode: wgpu::VertexStepMode::Instance,
3299            attributes: &[
3300                wgpu::VertexAttribute {
3301                    shader_location: 0,
3302                    offset: 0,
3303                    format: wgpu::VertexFormat::Float32x4,
3304                },
3305                wgpu::VertexAttribute {
3306                    shader_location: 1,
3307                    offset: 16,
3308                    format: wgpu::VertexFormat::Float32x4,
3309                },
3310                wgpu::VertexAttribute {
3311                    shader_location: 2,
3312                    offset: 32,
3313                    format: wgpu::VertexFormat::Float32x4,
3314                },
3315            ],
3316        };
3317        // Bind layout for per-draw vector mesh uniforms (dynamic offset).
3318        let mesh_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3319            label: Some("mesh uniform layout"),
3320            entries: &[wgpu::BindGroupLayoutEntry {
3321                binding: 0,
3322                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3323                ty: wgpu::BindingType::Buffer {
3324                    ty: wgpu::BufferBindingType::Uniform,
3325                    has_dynamic_offset: true,
3326                    min_binding_size: NonZeroU64::new(mesh_uniform_size),
3327                },
3328                count: None,
3329            }],
3330        });
3331        let mesh_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3332            label: Some("mesh uniform buffer"),
3333            size: mesh_uniform_cap,
3334            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3335            mapped_at_creation: false,
3336        });
3337        let mesh_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3338            label: Some("mesh uniform bind"),
3339            layout: &mesh_bind_layout,
3340            entries: &[wgpu::BindGroupEntry {
3341                binding: 0,
3342                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3343                    buffer: &mesh_uniform_buf,
3344                    offset: 0,
3345                    size: NonZeroU64::new(mesh_uniform_size),
3346                }),
3347            }],
3348        });
3349
3350        // Two sets of pipelines: one for the MSAA surface pass, one for layer
3351        // render-to-texture passes (sample_count = 1).
3352        let surface_pipes = Pipelines::create(
3353            &device,
3354            output_format,
3355            msaa_samples,
3356            &globals_layout,
3357            &text_bind_layout,
3358            &image_bind_layout_nv12,
3359            &clip_pipeline_layout,
3360            &stencil_for_content,
3361            &stencil_for_clip_inc,
3362            &stencil_for_clip_dec,
3363            &clip_vertex_layout,
3364            &mesh_bind_layout,
3365        );
3366        let working_space_pipes = Pipelines::create(
3367            &device,
3368            wgpu::TextureFormat::Rgba16Float,
3369            working_space_msaa_samples,
3370            &globals_layout,
3371            &text_bind_layout,
3372            &image_bind_layout_nv12,
3373            &clip_pipeline_layout,
3374            &stencil_for_content,
3375            &stencil_for_clip_inc,
3376            &stencil_for_clip_dec,
3377            &clip_vertex_layout,
3378            &mesh_bind_layout,
3379        );
3380        let layer_pipes = if msaa_samples == 1 {
3381            surface_pipes.clone()
3382        } else {
3383            Pipelines::create(
3384                &device,
3385                output_format,
3386                1,
3387                &globals_layout,
3388                &text_bind_layout,
3389                &image_bind_layout_nv12,
3390                &clip_pipeline_layout,
3391                &stencil_for_content,
3392                &stencil_for_clip_inc,
3393                &stencil_for_clip_dec,
3394                &clip_vertex_layout,
3395                &mesh_bind_layout,
3396            )
3397        };
3398        let working_space_layer_pipes = if working_space_msaa_samples == 1 {
3399            working_space_pipes.clone()
3400        } else {
3401            Pipelines::create(
3402                &device,
3403                wgpu::TextureFormat::Rgba16Float,
3404                1,
3405                &globals_layout,
3406                &text_bind_layout,
3407                &image_bind_layout_nv12,
3408                &clip_pipeline_layout,
3409                &stencil_for_content,
3410                &stencil_for_clip_inc,
3411                &stencil_for_clip_dec,
3412                &clip_vertex_layout,
3413                &mesh_bind_layout,
3414            )
3415        };
3416
3417        // Vector glyph rendering always available with tessellation+MSAA approach.
3418        let slug_enabled = true;
3419
3420        // Blur composite ring (for graphics-layer drop shadows)
3421        let blur_ring = UploadRing::new(
3422            &device,
3423            "blur ring",
3424            1024 * 1024,
3425            wgpu::BufferUsages::VERTEX
3426                | wgpu::BufferUsages::COPY_DST
3427                | wgpu::BufferUsages::COPY_SRC,
3428        );
3429
3430        // Atlases
3431        let atlas_mask = init_atlas_mask(&device);
3432        let atlas_color = init_atlas_color(&device);
3433        let atlas_mask_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3434            label: Some("atlas mask bind"),
3435            layout: &text_bind_layout,
3436            entries: &[
3437                wgpu::BindGroupEntry {
3438                    binding: 0,
3439                    resource: wgpu::BindingResource::TextureView(&atlas_mask.view),
3440                },
3441                wgpu::BindGroupEntry {
3442                    binding: 1,
3443                    resource: wgpu::BindingResource::Sampler(&atlas_mask.sampler),
3444                },
3445            ],
3446        });
3447        let atlas_color_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3448            label: Some("atlas color bind"),
3449            layout: &text_bind_layout,
3450            entries: &[
3451                wgpu::BindGroupEntry {
3452                    binding: 0,
3453                    resource: wgpu::BindingResource::TextureView(&atlas_color.view),
3454                },
3455                wgpu::BindGroupEntry {
3456                    binding: 1,
3457                    resource: wgpu::BindingResource::Sampler(&atlas_color.sampler),
3458                },
3459            ],
3460        });
3461
3462        // Upload rings
3463        let ring_rect = UploadRing::new(
3464            &device,
3465            "ring rect",
3466            1 << 20,
3467            wgpu::BufferUsages::VERTEX
3468                | wgpu::BufferUsages::COPY_DST
3469                | wgpu::BufferUsages::COPY_SRC,
3470        );
3471        let ring_border = UploadRing::new(
3472            &device,
3473            "ring border",
3474            1 << 20,
3475            wgpu::BufferUsages::VERTEX
3476                | wgpu::BufferUsages::COPY_DST
3477                | wgpu::BufferUsages::COPY_SRC,
3478        );
3479        let ring_ellipse = UploadRing::new(
3480            &device,
3481            "ring ellipse",
3482            1 << 20,
3483            wgpu::BufferUsages::VERTEX
3484                | wgpu::BufferUsages::COPY_DST
3485                | wgpu::BufferUsages::COPY_SRC,
3486        );
3487        let ring_ellipse_border = UploadRing::new(
3488            &device,
3489            "ring ellipse border",
3490            1 << 20,
3491            wgpu::BufferUsages::VERTEX
3492                | wgpu::BufferUsages::COPY_DST
3493                | wgpu::BufferUsages::COPY_SRC,
3494        );
3495        let ring_arc = UploadRing::new(
3496            &device,
3497            "ring arc",
3498            1 << 20,
3499            wgpu::BufferUsages::VERTEX
3500                | wgpu::BufferUsages::COPY_DST
3501                | wgpu::BufferUsages::COPY_SRC,
3502        );
3503        let ring_glyph_mask = UploadRing::new(
3504            &device,
3505            "ring glyph mask",
3506            1 << 20,
3507            wgpu::BufferUsages::VERTEX
3508                | wgpu::BufferUsages::COPY_DST
3509                | wgpu::BufferUsages::COPY_SRC,
3510        );
3511        let ring_glyph_color = UploadRing::new(
3512            &device,
3513            "ring glyph color",
3514            1 << 20,
3515            wgpu::BufferUsages::VERTEX
3516                | wgpu::BufferUsages::COPY_DST
3517                | wgpu::BufferUsages::COPY_SRC,
3518        );
3519        let ring_slug = UploadRing::new(
3520            &device,
3521            "ring slug",
3522            1 << 22,
3523            wgpu::BufferUsages::VERTEX
3524                | wgpu::BufferUsages::COPY_DST
3525                | wgpu::BufferUsages::COPY_SRC,
3526        );
3527        let ring_clip = UploadRing::new(
3528            &device,
3529            "ring clip",
3530            1 << 16,
3531            wgpu::BufferUsages::VERTEX
3532                | wgpu::BufferUsages::COPY_DST
3533                | wgpu::BufferUsages::COPY_SRC,
3534        );
3535        let blend_ring = UploadRing::new(
3536            &device,
3537            "ring blend",
3538            1 << 16,
3539            wgpu::BufferUsages::VERTEX
3540                | wgpu::BufferUsages::COPY_DST
3541                | wgpu::BufferUsages::COPY_SRC,
3542        );
3543        let ring_projective = UploadRing::new(
3544            &device,
3545            "ring projective",
3546            1 << 16,
3547            wgpu::BufferUsages::VERTEX
3548                | wgpu::BufferUsages::COPY_DST
3549                | wgpu::BufferUsages::COPY_SRC,
3550        );
3551        let ring_nv12 = UploadRing::new(
3552            &device,
3553            "ring nv12",
3554            1 << 20,
3555            wgpu::BufferUsages::VERTEX
3556                | wgpu::BufferUsages::COPY_DST
3557                | wgpu::BufferUsages::COPY_SRC,
3558        );
3559        // Placeholder textures
3560        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
3561            label: Some("temp ds"),
3562            size: wgpu::Extent3d {
3563                width: 1,
3564                height: 1,
3565                depth_or_array_layers: 1,
3566            },
3567            mip_level_count: 1,
3568            sample_count: 1,
3569            dimension: wgpu::TextureDimension::D2,
3570            format: wgpu::TextureFormat::Depth24PlusStencil8,
3571            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3572            view_formats: &[],
3573        });
3574        let depth_stencil_view =
3575            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
3576
3577        let mut renderer = WgpuSceneRenderer {
3578            device,
3579            queue,
3580            output_format,
3581            surface_format: output_format,
3582            output_width: 0,
3583            output_height: 0,
3584            pixels_per_point: 1.0,
3585
3586            surface_pipes,
3587            working_space_pipes,
3588            layer_pipes,
3589            working_space_layer_pipes,
3590
3591            rects: InstancedPipe::new(ring_rect),
3592            borders: InstancedPipe::new(ring_border),
3593            ellipses: InstancedPipe::new(ring_ellipse),
3594            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
3595            arcs: InstancedPipe::new(ring_arc),
3596            glyph_mask: InstancedPipe::new(ring_glyph_mask),
3597            glyph_color: InstancedPipe::new(ring_glyph_color),
3598
3599            text_bind_layout,
3600
3601            image_bind_layout_rgba,
3602            image_bind_layout_nv12,
3603            image_sampler,
3604            image_sampler_nearest,
3605            image_sampler_repeat,
3606            image_sampler_repeat_nearest,
3607            layer_sampler,
3608            layer_sampler_linear,
3609
3610            blur_ring,
3611
3612            slug_enabled,
3613            slug_ring: ring_slug,
3614            slug_cache: slug::GlyphSlugCache::new(),
3615            slug_draw_cache: HashMap::new(),
3616            slug_draw_cache_bytes_total: 0,
3617
3618            clip_ring: ring_clip,
3619
3620            nv12: InstancedPipe::new(ring_nv12),
3621
3622            mesh_cache: HashMap::new(),
3623            mesh_cache_bytes_total: 0,
3624            mesh_uniform_buf,
3625            mesh_bind,
3626            mesh_uniform_head: 0,
3627            mesh_uniform_alignment,
3628            mesh_uniform_cap,
3629
3630            projective_ring: ring_projective,
3631            blend_ring,
3632            flatten_layer_ids: Vec::new(),
3633            blur_scratch_ids: HashMap::new(),
3634            blur_scratch_next: 0,
3635            blend_snapshots: std::collections::HashMap::new(),
3636            blend_snapshot_pool: HashMap::new(),
3637            blend_copies: Vec::new(),
3638
3639            msaa_samples,
3640            working_space_msaa_samples,
3641            depth_stencil_tex,
3642            depth_stencil_view,
3643            msaa_tex: None,
3644            msaa_view: None,
3645            ws_msaa_tex: None,
3646            ws_msaa_view: None,
3647            surface_resolve_tex: None,
3648            surface_resolve_view: None,
3649            surface_resolve_bytes: 0,
3650            msaa_bytes: 0,
3651            ws_msaa_bytes: 0,
3652            depth_stencil_bytes: 0,
3653            working_space_bytes: 0,
3654            gpu_budget_bytes: MAX_GPU_RESOURCE_BYTES,
3655            globals_bind,
3656            globals_buf,
3657            globals_staging,
3658            globals_staging_cap,
3659
3660            atlas_mask,
3661            atlas_color,
3662            atlas_mask_bind,
3663            atlas_color_bind,
3664            atlas_generation: repose_text::font_generation(),
3665            atlas_mask_pending: AtlasPending::default(),
3666            atlas_color_pending: AtlasPending::default(),
3667            atlas_last_compact_frame: 0,
3668            atlas_mask_failures: HashSet::new(),
3669            atlas_color_failures: HashSet::new(),
3670            atlas_mask_deferred: HashSet::new(),
3671            atlas_color_deferred: HashSet::new(),
3672            glyph_outline_cache: HashMap::new(),
3673            legacy_shaped_text_cache: HashMap::new(),
3674
3675            next_image_handle: 1,
3676            images: HashMap::new(),
3677            retained: HashMap::new(),
3678            retained_bytes_total: 0,
3679
3680            next_coverage_handle: 1,
3681            coverages: HashMap::new(),
3682
3683            frame_index: 0,
3684            cache_touch_clock: 0,
3685            image_bytes_total: 0,
3686            image_evict_after_frames: 600,         // ~10s @ 60fps
3687            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
3688            layer_pool: HashMap::new(),
3689            producer_layer_ids: HashSet::new(),
3690            layer_bytes_total: 0,
3691            transient_layer_bytes_total: 0,
3692
3693            working_space: false,
3694            ws_tex: None,
3695            ws_view: None,
3696            ws_bind: None,
3697            display_pipeline: None,
3698            display_layout: None,
3699
3700            callback_resources: CallbackResources::default(),
3701            callback_scoped_resources: HashMap::new(),
3702            callback_scope_uses: HashMap::new(),
3703            callback_scope_payloads: HashMap::new(),
3704            callback_scope_clock: 0,
3705            blend_snapshot_bytes_total: 0,
3706            frame_active: false,
3707            last_render_error: None,
3708        };
3709
3710        renderer.recreate_msaa_and_depth_stencil();
3711        Ok(renderer)
3712    }
3713}
3714
3715/// wgpu backend names accepted by [`parse_backends`], mapped from the
3716/// canonical [`wgpu::Backend::to_str`] spelling plus the aliases people
3717/// actually type.
3718#[cfg(not(target_arch = "wasm32"))]
3719fn backend_by_name(name: &str) -> Option<wgpu::Backend> {
3720    match name {
3721        "gl" | "opengl" | "opengles" | "webgl" => Some(wgpu::Backend::Gl),
3722        "webgpu" | "browser-webgpu" => Some(wgpu::Backend::BrowserWebGpu),
3723        "metal" => Some(wgpu::Backend::Metal),
3724        "vulkan" => Some(wgpu::Backend::Vulkan),
3725        "dx12" | "d3d12" => Some(wgpu::Backend::Dx12),
3726        _ => None,
3727    }
3728}
3729
3730/// Parse a comma/space separated wgpu backend list such as `gl` or
3731/// `vulkan,gl`. Unknown names are dropped with a warning rather than
3732/// failing the whole spec, and `None` means "nothing valid was requested".
3733#[cfg(not(target_arch = "wasm32"))]
3734pub fn parse_backends(spec: &str) -> Option<wgpu::Backends> {
3735    let mut bits = wgpu::Backends::empty();
3736    for name in spec.split([',', ' ', '+']).filter(|n| !n.is_empty()) {
3737        match backend_by_name(&name.to_ascii_lowercase()) {
3738            Some(backend) => bits |= wgpu::Backends::from(backend),
3739            None => log::warn!(
3740                "REPOSE_WGPU_BACKENDS: ignoring unknown backend {name:?}; \
3741                 expected one of noop, vulkan, metal, dx12, gl, webgpu"
3742            ),
3743        }
3744    }
3745    (!bits.is_empty()).then_some(bits)
3746}
3747
3748#[cfg(feature = "winit-surface")]
3749pub(crate) async fn surface_instance(
3750    display: Option<winit::event_loop::OwnedDisplayHandle>,
3751) -> (Instance, wgpu::Backends) {
3752    // The display handle is what lets wgpu's GLES backend reach a real
3753    // platform surface. Without it wgpu falls back to a surfaceless/legacy-EGL
3754    // display, and on Wayland the GL adapter is then rejected outright as
3755    // surface-incompatible, which kills the OpenGL fallback entirely.
3756    let mut desc = match display {
3757        Some(handle) => wgpu::InstanceDescriptor::new_with_display_handle(Box::new(handle)),
3758        None => wgpu::InstanceDescriptor::new_without_display_handle(),
3759    };
3760    if cfg!(target_arch = "wasm32") {
3761        desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
3762    }
3763    #[cfg(not(target_arch = "wasm32"))]
3764    if let Ok(spec) = std::env::var("REPOSE_WGPU_BACKENDS")
3765        && let Some(bits) = parse_backends(&spec)
3766    {
3767        log::info!("REPOSE_WGPU_BACKENDS={spec} -> {bits:?}");
3768        desc.backends = bits;
3769    }
3770
3771    let backends = desc.backends;
3772    let instance = if cfg!(target_arch = "wasm32") {
3773        wgpu::util::new_instance_with_webgpu_detection(desc).await
3774    } else {
3775        wgpu::Instance::new(desc)
3776    };
3777    (instance, backends)
3778}
3779
3780impl WgpuSurfaceBackend {
3781    #[cfg(feature = "winit-surface")]
3782    pub async fn new_async(
3783        window: Arc<winit::window::Window>,
3784    ) -> anyhow::Result<WgpuSurfaceBackend> {
3785        Self::new_async_with_options(window, None, 4, PresentModePref::Auto).await
3786    }
3787
3788    /// Create a windowed surface backend, honoring the requested MSAA sample
3789    /// count (falling back to the largest supported count <= `msaa_samples`).
3790    #[cfg(feature = "winit-surface")]
3791    pub async fn new_async_with_msaa(
3792        window: Arc<winit::window::Window>,
3793        msaa_samples: u32,
3794    ) -> anyhow::Result<WgpuSurfaceBackend> {
3795        Self::new_async_with_options(window, None, msaa_samples, PresentModePref::Auto).await
3796    }
3797
3798    /// Create a windowed surface backend, honoring the requested MSAA sample
3799    /// count and present-mode preference.
3800    ///
3801    /// `display` must be the event loop's owned display handle on native
3802    /// platforms, otherwise the GL backend cannot present (see
3803    /// [`surface_instance`]).
3804    #[cfg(feature = "winit-surface")]
3805    pub async fn new_async_with_options(
3806        window: Arc<winit::window::Window>,
3807        display: Option<winit::event_loop::OwnedDisplayHandle>,
3808        msaa_samples: u32,
3809        present_mode: PresentModePref,
3810    ) -> anyhow::Result<WgpuSurfaceBackend> {
3811        let (instance, backends) = surface_instance(display).await;
3812
3813        let surface = instance.create_surface(window.clone())?;
3814
3815        let adapter = instance
3816            .request_adapter(&wgpu::RequestAdapterOptions {
3817                power_preference: wgpu::PowerPreference::HighPerformance,
3818                compatible_surface: Some(&surface),
3819                force_fallback_adapter: false,
3820                apply_limit_buckets: false,
3821            })
3822            .await
3823            .map_err(|e| {
3824                anyhow::anyhow!("no adapter for this window (enabled backends {backends:?}): {e:?}")
3825            })?;
3826        log::info!(
3827            "wgpu adapter: {} backend={:?} driver={:?} type={:?}",
3828            adapter.get_info().name,
3829            adapter.get_info().backend,
3830            adapter.get_info().driver,
3831            adapter.get_info().device_type,
3832        );
3833
3834        let limits = adapter.limits();
3835
3836        let features = {
3837            let available = adapter.features();
3838            let mut features = wgpu::Features::empty();
3839            if available.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) {
3840                features |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
3841            }
3842            #[cfg(target_os = "linux")]
3843            {
3844                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
3845                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
3846                }
3847                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
3848                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
3849                }
3850            }
3851            features
3852        };
3853
3854        let (device, queue) = adapter
3855            .request_device(&wgpu::DeviceDescriptor {
3856                label: Some("repose-rs device"),
3857                required_features: features,
3858                required_limits: limits,
3859                experimental_features: wgpu::ExperimentalFeatures::disabled(),
3860                memory_hints: wgpu::MemoryHints::default(),
3861                trace: wgpu::Trace::Off,
3862            })
3863            .await
3864            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
3865
3866        let device_lost = Arc::new(Mutex::new(None));
3867        {
3868            let device_lost = device_lost.clone();
3869            device.set_device_lost_callback(move |reason, message| {
3870                log::error!("wgpu device lost ({reason:?}): {message}");
3871                if let Ok(mut slot) = device_lost.lock() {
3872                    *slot = Some((reason, message));
3873                }
3874            });
3875        }
3876
3877        let size = window.inner_size();
3878
3879        let caps = surface.get_capabilities(&adapter);
3880
3881        let Some(format) = caps.formats.first().copied() else {
3882            anyhow::bail!(
3883                "surface exposes no presentable formats (adapter {})",
3884                adapter.get_info().name
3885            );
3886        };
3887
3888        let (format, view_format) = if cfg!(target_arch = "wasm32")
3889            && adapter
3890                .get_downlevel_capabilities()
3891                .flags
3892                .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS)
3893        {
3894            let non_srgb = caps
3895                .formats
3896                .iter()
3897                .copied()
3898                .find(|f| !f.is_srgb())
3899                .unwrap_or(format);
3900            (non_srgb, Some(non_srgb.add_srgb_suffix()))
3901        } else if cfg!(target_arch = "wasm32") {
3902            let fmt = caps
3903                .formats
3904                .iter()
3905                .copied()
3906                .find(|f| f.is_srgb())
3907                .unwrap_or(format);
3908            (fmt, None)
3909        } else {
3910            let fmt = caps
3911                .formats
3912                .iter()
3913                .copied()
3914                .find(|f| f.is_srgb())
3915                .unwrap_or(format);
3916            (fmt, None)
3917        };
3918
3919        let present_mode = pick_present_mode(&caps, present_mode);
3920        let alpha_mode = *caps
3921            .alpha_modes
3922            .first()
3923            .ok_or_else(|| anyhow::anyhow!("surface exposes no alpha modes"))?;
3924
3925        let render_format = view_format.unwrap_or(format);
3926        let msaa_samples = pick_surface_msaa(&adapter, render_format, msaa_samples);
3927        let mut renderer = WgpuSceneRenderer::try_from_device_with_adapter(
3928            &adapter,
3929            device,
3930            queue,
3931            render_format,
3932            msaa_samples,
3933        )?
3934        .with_surface_format(format)?;
3935        let (fw, fh) = clamp_output_size(
3936            size.width,
3937            size.height,
3938            renderer.device.limits().max_texture_dimension_2d,
3939        );
3940        renderer.resize(fw, fh);
3941
3942        let view_formats = view_format.into_iter().collect::<Vec<_>>();
3943
3944        // `COPY_SRC` lets an isolated blend snapshot the backdrop straight from
3945        // the swapchain when there is no multisample resolve target to copy
3946        // from. Canvas textures may not be storage-bound, but copying from them
3947        // is allowed. Not every backend grants it - GL surfaces are
3948        // render-target only - and the snapshot path already skips a source
3949        // without `COPY_SRC`, so request the intersection rather than making
3950        // `configure` fail and taking down every backend that lacks the usage.
3951        let wanted_usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
3952        let surface_usage = wanted_usage & caps.usages;
3953        if surface_usage != wanted_usage {
3954            log::warn!(
3955                "surface {format:?} cannot be used as {:?}; swapchain-sourced blend snapshots \
3956                 are skipped on this backend",
3957                wanted_usage - surface_usage
3958            );
3959        }
3960        let config = wgpu::SurfaceConfiguration {
3961            usage: surface_usage,
3962            format,
3963            width: fw.max(1),
3964            height: fh.max(1),
3965            present_mode,
3966            alpha_mode,
3967            color_space: wgpu::SurfaceColorSpace::Auto,
3968            view_formats,
3969            desired_maximum_frame_latency: 1,
3970        };
3971        surface.configure(&renderer.device, &config);
3972
3973        Ok(WgpuSurfaceBackend {
3974            #[cfg(feature = "winit-surface")]
3975            instance: Some(instance),
3976            #[cfg(feature = "winit-surface")]
3977            window: Some(window),
3978            surface: Some(surface),
3979            surface_config: Some(config),
3980            pending_reconfigure: false,
3981            renderer,
3982            device_lost,
3983        })
3984    }
3985
3986    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3987    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
3988        pollster::block_on(Self::new_async(window))
3989    }
3990
3991    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
3992    pub fn new_with_msaa(
3993        window: Arc<winit::window::Window>,
3994        msaa_samples: u32,
3995    ) -> anyhow::Result<WgpuSurfaceBackend> {
3996        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
3997    }
3998
3999    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4000    pub fn new_with_options(
4001        window: Arc<winit::window::Window>,
4002        display: Option<winit::event_loop::OwnedDisplayHandle>,
4003        msaa_samples: u32,
4004        present_mode: PresentModePref,
4005    ) -> anyhow::Result<WgpuSurfaceBackend> {
4006        pollster::block_on(Self::new_async_with_options(
4007            window,
4008            display,
4009            msaa_samples,
4010            present_mode,
4011        ))
4012    }
4013
4014    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4015    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4016        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
4017    }
4018
4019    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4020    pub fn new_with_msaa(
4021        _window: Arc<winit::window::Window>,
4022        _msaa_samples: u32,
4023    ) -> anyhow::Result<WgpuSurfaceBackend> {
4024        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
4025    }
4026
4027    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4028    pub fn new_with_options(
4029        _window: Arc<winit::window::Window>,
4030        _display: Option<winit::event_loop::OwnedDisplayHandle>,
4031        _msaa_samples: u32,
4032        _present_mode: PresentModePref,
4033    ) -> anyhow::Result<WgpuSurfaceBackend> {
4034        anyhow::bail!(
4035            "Use WgpuSurfaceBackend::new_async_with_options(window, display, msaa, mode).await on wasm32"
4036        )
4037    }
4038
4039    /// Reason the device was lost, consumed once. `None` while healthy and
4040    /// after an ordinary `Destroyed`, which is what dropping this backend
4041    /// produces; runners use this to tell a crash/reset apart from teardown
4042    /// and rebuild the backend.
4043    pub fn take_device_lost(&self) -> Option<String> {
4044        let (reason, message) = self.device_lost.lock().ok()?.take()?;
4045        (reason != wgpu::DeviceLostReason::Destroyed).then_some(message)
4046    }
4047}
4048
4049/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
4050/// Fifo-first selection when the preferred mode is unavailable.
4051#[cfg(feature = "winit-surface")]
4052fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
4053    let auto = || {
4054        caps.present_modes
4055            .iter()
4056            .copied()
4057            .find(|m| *m == wgpu::PresentMode::Fifo)
4058            .or_else(|| {
4059                caps.present_modes
4060                    .iter()
4061                    .copied()
4062                    .find(|m| *m == wgpu::PresentMode::Mailbox)
4063            })
4064            .unwrap_or(wgpu::PresentMode::Immediate)
4065    };
4066    match pref {
4067        PresentModePref::Auto => auto(),
4068        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
4069            wgpu::PresentMode::Fifo
4070        }
4071        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
4072            wgpu::PresentMode::Mailbox
4073        }
4074        PresentModePref::Immediate
4075            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
4076        {
4077            wgpu::PresentMode::Immediate
4078        }
4079        _ => auto(),
4080    }
4081}
4082
4083/// `wgpu` only accepts these sample counts; anything else (3, 5, 17, 0) is
4084/// a hard validation error at pipeline creation.
4085const VALID_SAMPLE_COUNTS: [u32; 5] = [1, 2, 4, 8, 16];
4086
4087fn validate_sample_count(label: &str, samples: u32) -> anyhow::Result<()> {
4088    if !VALID_SAMPLE_COUNTS.contains(&samples) {
4089        anyhow::bail!(
4090            "{label} must be one of {VALID_SAMPLE_COUNTS:?} (wgpu-supported sample counts), got {samples}"
4091        );
4092    }
4093    Ok(())
4094}
4095
4096/// Every UI pipeline writes `output_format` with premultiplied-alpha
4097/// blending, so those two properties have to hold before any pipeline is
4098/// built. Multisample resolve is only required when more than one sample is
4099/// actually in use: a format that is renderable and blendable but cannot
4100/// resolve is fine at x1, which is what [`pick_surface_msaa`] falls back to.
4101fn validate_render_target_format(
4102    adapter: &wgpu::Adapter,
4103    output_format: wgpu::TextureFormat,
4104    samples: u32,
4105) -> anyhow::Result<()> {
4106    let features = adapter.get_texture_format_features(output_format);
4107    let flags = features.flags;
4108    for (needed, what) in [
4109        (wgpu::TextureUsages::RENDER_ATTACHMENT, "render attachment"),
4110        (wgpu::TextureUsages::TEXTURE_BINDING, "texture binding"),
4111    ] {
4112        if !features.allowed_usages.contains(needed) {
4113            anyhow::bail!("{output_format:?} cannot be used as a {what}");
4114        }
4115    }
4116    if !flags.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) {
4117        anyhow::bail!("{output_format:?} cannot be blended into");
4118    }
4119    if samples > 1 && !flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE) {
4120        anyhow::bail!(
4121            "{output_format:?} cannot resolve the requested {samples}x multisampled attachment"
4122        );
4123    }
4124    // `set_working_space` renders into Rgba16Float and resolves into it.
4125    let ws = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4126    if !ws
4127        .allowed_usages
4128        .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4129        || !ws
4130            .allowed_usages
4131            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4132    {
4133        anyhow::bail!(
4134            "{:?} cannot be used for linear working-space rendering",
4135            wgpu::TextureFormat::Rgba16Float
4136        );
4137    }
4138    Ok(())
4139}
4140
4141/// Pick the MSAA sample count for the surface pass, honoring `requested` and
4142/// falling back to the largest supported count <= it.
4143pub fn pick_surface_msaa(
4144    adapter: &wgpu::Adapter,
4145    format: wgpu::TextureFormat,
4146    requested: u32,
4147) -> u32 {
4148    pick_surface_msaa_for_mode(adapter, format, requested, false)
4149}
4150
4151pub fn pick_surface_msaa_for_mode(
4152    adapter: &wgpu::Adapter,
4153    format: wgpu::TextureFormat,
4154    requested: u32,
4155    working_space: bool,
4156) -> u32 {
4157    let requested = requested.max(1);
4158    let color_feat = adapter.get_texture_format_features(format);
4159    let working_space_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4160    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
4161    let supported = |n: u32| {
4162        color_feat
4163            .allowed_usages
4164            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4165            && color_feat.flags.sample_count_supported(n)
4166            && (!working_space
4167                || (working_space_feat
4168                    .allowed_usages
4169                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4170                    && working_space_feat.flags.sample_count_supported(n)
4171                    && (n == 1
4172                        || working_space_feat
4173                            .flags
4174                            .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))))
4175            && (n == 1
4176                || color_feat
4177                    .flags
4178                    .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))
4179            && depth_feat
4180                .allowed_usages
4181                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4182            && depth_feat.flags.sample_count_supported(n)
4183    };
4184    let mut candidates = vec![requested];
4185    for n in [8, 4, 2, 1] {
4186        if n < requested {
4187            candidates.push(n);
4188        }
4189    }
4190    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
4191    if chosen != requested {
4192        log::info!("requested MSAA x{requested}, using x{chosen}");
4193    }
4194    chosen
4195}
4196
4197impl WgpuSceneRenderer {
4198    // Image API
4199
4200    pub fn set_image_from_bytes(
4201        &mut self,
4202        handle: u64,
4203        data: &[u8],
4204        srgb: bool,
4205    ) -> anyhow::Result<()> {
4206        validate_image_handle(handle)?;
4207        let max_dimension = self.device.limits().max_texture_dimension_2d;
4208        let (w, h, rgba) = decode_image_rgba8(data, max_dimension)?;
4209        self.set_image_rgba8(handle, w, h, &rgba, srgb)?;
4210        // Encoded uploads have no size at the call site, so publish it here for
4211        // layout - only once the pixels are actually on the GPU.
4212        repose_core::set_image_intrinsic_size(handle, w, h);
4213        Ok(())
4214    }
4215
4216    pub fn set_image_rgba8(
4217        &mut self,
4218        handle: u64,
4219        w: u32,
4220        h: u32,
4221        rgba: &[u8],
4222        srgb: bool,
4223    ) -> anyhow::Result<()> {
4224        validate_image_handle(handle)?;
4225        validate_texture_dimensions(&self.device, w, h)?;
4226        let expected = checked_image_bytes_usize(w, h, 4)?;
4227        if rgba.len() < expected {
4228            return Err(anyhow::anyhow!(
4229                "RGBA buffer too small: {} < {}",
4230                rgba.len(),
4231                expected
4232            ));
4233        }
4234
4235        let format = if srgb {
4236            wgpu::TextureFormat::Rgba8UnormSrgb
4237        } else {
4238            wgpu::TextureFormat::Rgba8Unorm
4239        };
4240
4241        let needs_recreate = match self.images.get(&handle) {
4242            Some(ImageTex::Rgba {
4243                w: cw,
4244                h: ch,
4245                format: cf,
4246                ..
4247            }) => *cw != w || *ch != h || *cf != format,
4248            _ => true,
4249        };
4250
4251        if needs_recreate {
4252            self.remove_image(handle);
4253
4254            let (tex, binds) = self.create_rgba_tex(w, h, format);
4255            let bytes = checked_image_bytes(w, h, 4)?;
4256            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4257
4258            self.images.insert(
4259                handle,
4260                ImageTex::Rgba {
4261                    tex,
4262                    binds,
4263                    w,
4264                    h,
4265                    format,
4266                    last_used_frame: self.frame_index,
4267                    bytes,
4268                },
4269            );
4270        }
4271
4272        if let Some(old) = self.retained.remove(&handle) {
4273            self.retained_bytes_total = self
4274                .retained_bytes_total
4275                .saturating_sub(old.rgba.len() as u64);
4276        }
4277        self.retained_bytes_total = self.retained_bytes_total.saturating_add(expected as u64);
4278        self.retained.insert(
4279            handle,
4280            RetainedImage {
4281                w,
4282                h,
4283                format,
4284                rgba: rgba[..expected].to_vec(),
4285                last_used_frame: self.frame_index,
4286            },
4287        );
4288        self.evict_retained_excess();
4289
4290        let tex = match self.images.get(&handle) {
4291            Some(ImageTex::Rgba { tex, .. }) => tex,
4292            _ => unreachable!(),
4293        };
4294
4295        self.queue.write_texture(
4296            wgpu::TexelCopyTextureInfo {
4297                texture: tex,
4298                mip_level: 0,
4299                origin: wgpu::Origin3d::ZERO,
4300                aspect: wgpu::TextureAspect::All,
4301            },
4302            &rgba[..expected],
4303            wgpu::TexelCopyBufferLayout {
4304                offset: 0,
4305                bytes_per_row: Some(4 * w),
4306                rows_per_image: Some(h),
4307            },
4308            wgpu::Extent3d {
4309                width: w,
4310                height: h,
4311                depth_or_array_layers: 1,
4312            },
4313        );
4314
4315        // Ensure budget limits
4316        self.evict_budget_excess();
4317
4318        Ok(())
4319    }
4320
4321    /// Create (but do not populate) the GPU texture, view and bind group for an
4322    /// RGBA image. Pixels are written separately via `write_texture`.
4323    fn create_image_binds(
4324        &self,
4325        view: &wgpu::TextureView,
4326        label: &str,
4327        sampler: Option<&wgpu::Sampler>,
4328    ) -> ImageBinds {
4329        let make = |name: &str, sampler: &wgpu::Sampler| {
4330            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4331                label: Some(name),
4332                layout: &self.image_bind_layout_rgba,
4333                entries: &[
4334                    wgpu::BindGroupEntry {
4335                        binding: 0,
4336                        resource: wgpu::BindingResource::TextureView(view),
4337                    },
4338                    wgpu::BindGroupEntry {
4339                        binding: 1,
4340                        resource: wgpu::BindingResource::Sampler(sampler),
4341                    },
4342                ],
4343            })
4344        };
4345        match sampler {
4346            Some(sampler) => {
4347                let bind = make(label, sampler);
4348                ImageBinds {
4349                    linear: make(label, sampler),
4350                    nearest: bind.clone(),
4351                    tile_linear: bind.clone(),
4352                    tile_nearest: bind,
4353                }
4354            }
4355            None => ImageBinds {
4356                linear: make(label, &self.image_sampler),
4357                nearest: make(label, &self.image_sampler_nearest),
4358                tile_linear: make(label, &self.image_sampler_repeat),
4359                tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4360            },
4361        }
4362    }
4363
4364    fn create_nv12_binds(
4365        &self,
4366        view_y: &wgpu::TextureView,
4367        view_uv: &wgpu::TextureView,
4368        yuv_buf: &wgpu::Buffer,
4369        label: &str,
4370    ) -> ImageBinds {
4371        let make = |name: &str, sampler: &wgpu::Sampler| {
4372            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4373                label: Some(name),
4374                layout: &self.image_bind_layout_nv12,
4375                entries: &[
4376                    wgpu::BindGroupEntry {
4377                        binding: 0,
4378                        resource: wgpu::BindingResource::TextureView(view_y),
4379                    },
4380                    wgpu::BindGroupEntry {
4381                        binding: 1,
4382                        resource: wgpu::BindingResource::TextureView(view_uv),
4383                    },
4384                    wgpu::BindGroupEntry {
4385                        binding: 2,
4386                        resource: wgpu::BindingResource::Sampler(sampler),
4387                    },
4388                    wgpu::BindGroupEntry {
4389                        binding: 3,
4390                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4391                            buffer: yuv_buf,
4392                            offset: 0,
4393                            size: None,
4394                        }),
4395                    },
4396                ],
4397            })
4398        };
4399        ImageBinds {
4400            linear: make(label, &self.image_sampler),
4401            nearest: make(label, &self.image_sampler_nearest),
4402            tile_linear: make(label, &self.image_sampler_repeat),
4403            tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4404        }
4405    }
4406
4407    fn create_rgba_tex(
4408        &self,
4409        w: u32,
4410        h: u32,
4411        format: wgpu::TextureFormat,
4412    ) -> (wgpu::Texture, ImageBinds) {
4413        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
4414            label: Some("user image rgba"),
4415            size: wgpu::Extent3d {
4416                width: w,
4417                height: h,
4418                depth_or_array_layers: 1,
4419            },
4420            mip_level_count: 1,
4421            sample_count: 1,
4422            dimension: wgpu::TextureDimension::D2,
4423            format,
4424            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4425            view_formats: &[],
4426        });
4427        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
4428        let binds = self.create_image_binds(&view, "image bind rgba", None);
4429        (tex, binds)
4430    }
4431
4432    /// Register an externally-created `wgpu::TextureView` as an image (zero-copy).
4433    fn validate_native_texture_view(
4434        &self,
4435        view: &wgpu::TextureView,
4436        width: u32,
4437        height: u32,
4438    ) -> anyhow::Result<()> {
4439        if width == 0 || height == 0 {
4440            anyhow::bail!("native texture dimensions must be non-zero");
4441        }
4442        let texture = view.texture();
4443        if texture.sample_count() != 1
4444            || texture.dimension() != wgpu::TextureDimension::D2
4445            || texture.depth_or_array_layers() != 1
4446            || texture.mip_level_count() == 0
4447        {
4448            anyhow::bail!("native texture must be a single-sample 2D texture with mip levels");
4449        }
4450        if width > self.device.limits().max_texture_dimension_2d
4451            || height > self.device.limits().max_texture_dimension_2d
4452        {
4453            anyhow::bail!("native texture dimensions exceed the device limit");
4454        }
4455        if !texture
4456            .usage()
4457            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4458        {
4459            anyhow::bail!("native texture lacks TEXTURE_BINDING usage");
4460        }
4461        if !native_format_supported(texture.format(), self.device.features()) {
4462            anyhow::bail!(
4463                "native texture format {:?} is not a filterable alpha color format",
4464                texture.format()
4465            );
4466        }
4467        if texture.width() < width || texture.height() < height {
4468            anyhow::bail!(
4469                "native texture is {}x{}, smaller than requested {width}x{height}",
4470                texture.width(),
4471                texture.height()
4472            );
4473        }
4474        Ok(())
4475    }
4476
4477    pub fn register_native_texture(
4478        &mut self,
4479        view: &wgpu::TextureView,
4480        width: u32,
4481        height: u32,
4482    ) -> u64 {
4483        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4484            log::warn!("register_native_texture: {error:#}");
4485            return 0;
4486        }
4487        let handle = self.next_image_handle;
4488        self.next_image_handle += 1;
4489        let binds = self.create_image_binds(view, "user native image", None);
4490        self.images.insert(
4491            handle,
4492            ImageTex::User {
4493                binds,
4494                w: width,
4495                h: height,
4496                last_used_frame: self.frame_index,
4497                bytes: 0,
4498            },
4499        );
4500        handle
4501    }
4502
4503    /// Like `register_native_texture` but with custom sampler descriptor.
4504    pub fn register_native_texture_with_sampler(
4505        &mut self,
4506        view: &wgpu::TextureView,
4507        sampler_desc: wgpu::SamplerDescriptor<'_>,
4508        width: u32,
4509        height: u32,
4510    ) -> u64 {
4511        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4512            log::warn!("register_native_texture: {error:#}");
4513            return 0;
4514        }
4515        if let Err(error) = validate_sampler_descriptor(&self.device, &sampler_desc) {
4516            log::warn!("register_native_texture: {error:#}");
4517            return 0;
4518        }
4519        let handle = self.next_image_handle;
4520        self.next_image_handle += 1;
4521        let sampler = self.device.create_sampler(&sampler_desc);
4522        let binds = self.create_image_binds(view, "user native image sampleropts", Some(&sampler));
4523        self.images.insert(
4524            handle,
4525            ImageTex::User {
4526                binds,
4527                w: width,
4528                h: height,
4529                last_used_frame: self.frame_index,
4530                bytes: 0,
4531            },
4532        );
4533        handle
4534    }
4535
4536    /// Update an existing native texture handle with a new view (reuse handle).
4537    pub fn update_native_texture(&mut self, handle: u64, view: &wgpu::TextureView) {
4538        if handle == 0 {
4539            log::warn!("update_native_texture: reserved handle");
4540            return;
4541        }
4542        let Some((w, h)) = self.images.get(&handle).and_then(|entry| match entry {
4543            ImageTex::User { w, h, .. } => Some((*w, *h)),
4544            _ => None,
4545        }) else {
4546            log::warn!("update_native_texture: handle {handle} is not a native image");
4547            return;
4548        };
4549        if let Err(error) = self.validate_native_texture_view(view, w, h) {
4550            log::warn!("update_native_texture: {error:#}");
4551            return;
4552        }
4553        let binds = self.create_image_binds(view, "user native image update", None);
4554        if let Some(entry) = self.images.get_mut(&handle) {
4555            *entry = ImageTex::User {
4556                binds,
4557                w,
4558                h,
4559                last_used_frame: self.frame_index,
4560                bytes: 0,
4561            };
4562        }
4563    }
4564
4565    pub fn set_image_nv12(
4566        &mut self,
4567        handle: u64,
4568        w: u32,
4569        h: u32,
4570        y: &[u8],
4571        uv: &[u8],
4572        color_info: ColorInfo,
4573    ) -> anyhow::Result<()> {
4574        validate_image_handle(handle)?;
4575        validate_texture_dimensions(&self.device, w, h)?;
4576        if self
4577            .images
4578            .get(&handle)
4579            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4580        {
4581            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4582        }
4583        let y_expected = checked_image_bytes_usize(w, h, 1)?;
4584        let uv_w = w.div_ceil(2);
4585        let uv_h = h.div_ceil(2);
4586        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 2)?;
4587
4588        if y.len() < y_expected {
4589            return Err(anyhow::anyhow!("Y plane too small"));
4590        }
4591        if uv.len() < uv_expected {
4592            return Err(anyhow::anyhow!("UV plane too small"));
4593        }
4594
4595        let needs_recreate = match self.images.get(&handle) {
4596            Some(ImageTex::Nv12 {
4597                w: ww,
4598                h: hh,
4599                pixel_format,
4600                ..
4601            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::Nv12,
4602            _ => true,
4603        };
4604
4605        // Compute the YUV->RGB transform on the CPU.
4606        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::Nv12);
4607
4608        if needs_recreate {
4609            self.remove_image(handle);
4610
4611            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4612                label: Some("nv12 Y"),
4613                size: wgpu::Extent3d {
4614                    width: w,
4615                    height: h,
4616                    depth_or_array_layers: 1,
4617                },
4618                mip_level_count: 1,
4619                sample_count: 1,
4620                dimension: wgpu::TextureDimension::D2,
4621                format: wgpu::TextureFormat::R8Unorm,
4622                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4623                view_formats: &[],
4624            });
4625            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4626
4627            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4628                label: Some("nv12 UV"),
4629                size: wgpu::Extent3d {
4630                    width: uv_w,
4631                    height: uv_h,
4632                    depth_or_array_layers: 1,
4633                },
4634                mip_level_count: 1,
4635                sample_count: 1,
4636                dimension: wgpu::TextureDimension::D2,
4637                format: wgpu::TextureFormat::Rg8Unorm,
4638                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4639                view_formats: &[],
4640            });
4641            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4642
4643            // Create a uniform buffer for the YUV transform (per-image).
4644            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4645                label: Some("nv12 yuv transform"),
4646                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4647                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4648                mapped_at_creation: false,
4649            });
4650
4651            // Write initial transform.
4652            self.queue
4653                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4654
4655            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "nv12 bind");
4656
4657            let bytes = checked_image_bytes(w, h, 1)?
4658                .checked_add(checked_image_bytes(uv_w, uv_h, 2)?)
4659                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4660                .ok_or_else(|| anyhow::anyhow!("NV12 image byte size overflow"))?;
4661            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4662
4663            self.images.insert(
4664                handle,
4665                ImageTex::Nv12 {
4666                    tex_y,
4667                    tex_uv,
4668                    binds,
4669                    yuv_buf,
4670                    w,
4671                    h,
4672                    pixel_format: PixelFormat::Nv12,
4673                    fourcc: Some(NV12_FOURCC),
4674                    color_info,
4675                    external: false,
4676                    last_used_frame: self.frame_index,
4677                    bytes,
4678                },
4679            );
4680        } else {
4681            // Re-use existing textures; just update the YUV transform if needed.
4682            if let Some(ImageTex::Nv12 {
4683                yuv_buf,
4684                color_info: stored_color,
4685                last_used_frame,
4686                ..
4687            }) = self.images.get_mut(&handle)
4688            {
4689                *stored_color = color_info;
4690                *last_used_frame = self.frame_index;
4691                self.queue
4692                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4693            }
4694        }
4695
4696        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4697            Some(ImageTex::Nv12 {
4698                tex_y,
4699                tex_uv,
4700                binds,
4701                ..
4702            }) => (tex_y, tex_uv, binds),
4703            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
4704        };
4705
4706        self.queue.write_texture(
4707            wgpu::TexelCopyTextureInfo {
4708                texture: tex_y,
4709                mip_level: 0,
4710                origin: wgpu::Origin3d::ZERO,
4711                aspect: wgpu::TextureAspect::All,
4712            },
4713            &y[..y_expected],
4714            wgpu::TexelCopyBufferLayout {
4715                offset: 0,
4716                bytes_per_row: Some(w),
4717                rows_per_image: Some(h),
4718            },
4719            wgpu::Extent3d {
4720                width: w,
4721                height: h,
4722                depth_or_array_layers: 1,
4723            },
4724        );
4725
4726        self.queue.write_texture(
4727            wgpu::TexelCopyTextureInfo {
4728                texture: tex_uv,
4729                mip_level: 0,
4730                origin: wgpu::Origin3d::ZERO,
4731                aspect: wgpu::TextureAspect::All,
4732            },
4733            &uv[..uv_expected],
4734            wgpu::TexelCopyBufferLayout {
4735                offset: 0,
4736                bytes_per_row: Some(2 * uv_w),
4737                rows_per_image: Some(uv_h),
4738            },
4739            wgpu::Extent3d {
4740                width: uv_w,
4741                height: uv_h,
4742                depth_or_array_layers: 1,
4743            },
4744        );
4745
4746        self.evict_budget_excess();
4747        Ok(())
4748    }
4749
4750    pub fn set_image_planes(
4751        &mut self,
4752        handle: u64,
4753        w: u32,
4754        h: u32,
4755        pixel_format: PixelFormat,
4756        planes: &[&[u8]],
4757        color_info: ColorInfo,
4758    ) -> anyhow::Result<()> {
4759        validate_image_handle(handle)?;
4760        if planes.len() != pixel_format.num_planes() as usize {
4761            anyhow::bail!(
4762                "{} requires {} planes, got {}",
4763                match pixel_format {
4764                    PixelFormat::Nv12 => "NV12",
4765                    PixelFormat::P010 => "P010",
4766                    PixelFormat::I420 => "I420",
4767                    PixelFormat::I444 => "I444",
4768                    PixelFormat::Rgba => "RGBA",
4769                },
4770                pixel_format.num_planes(),
4771                planes.len()
4772            );
4773        }
4774        match pixel_format {
4775            PixelFormat::Nv12 => {
4776                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4777                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4778                self.set_image_nv12(handle, w, h, y, uv, color_info)
4779            }
4780            PixelFormat::P010 => {
4781                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4782                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4783                self.set_image_p010(handle, w, h, y, uv, color_info)
4784            }
4785            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
4786                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
4787            )),
4788            PixelFormat::Rgba => {
4789                let rgba = planes
4790                    .first()
4791                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
4792                self.set_image_rgba8(handle, w, h, rgba, false)
4793            }
4794        }
4795    }
4796
4797    fn set_image_p010(
4798        &mut self,
4799        handle: u64,
4800        w: u32,
4801        h: u32,
4802        y: &[u8],
4803        uv: &[u8],
4804        color_info: ColorInfo,
4805    ) -> anyhow::Result<()> {
4806        validate_image_handle(handle)?;
4807        validate_texture_dimensions(&self.device, w, h)?;
4808        if self
4809            .images
4810            .get(&handle)
4811            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4812        {
4813            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4814        }
4815        if !self
4816            .device
4817            .features()
4818            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
4819        {
4820            anyhow::bail!("P010 requires TEXTURE_FORMAT_16BIT_NORM");
4821        }
4822        let uv_w = w.div_ceil(2);
4823        let uv_h = h.div_ceil(2);
4824
4825        let y_expected = checked_image_bytes_usize(w, h, 2)?;
4826        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 4)?;
4827
4828        if y.len() < y_expected {
4829            return Err(anyhow::anyhow!("P010 Y plane too small"));
4830        }
4831        if uv.len() < uv_expected {
4832            return Err(anyhow::anyhow!("P010 UV plane too small"));
4833        }
4834
4835        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
4836        // abstracts the storage format so R16Unorm/Rg16Unorm are
4837        // filterable float textures just like R8Unorm/Rg8Unorm).
4838        let needs_recreate = match self.images.get(&handle) {
4839            Some(ImageTex::Nv12 {
4840                w: ww,
4841                h: hh,
4842                pixel_format,
4843                ..
4844            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::P010,
4845            _ => true,
4846        };
4847
4848        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::P010);
4849
4850        if needs_recreate {
4851            self.remove_image(handle);
4852
4853            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4854                label: Some("p010 Y"),
4855                size: wgpu::Extent3d {
4856                    width: w,
4857                    height: h,
4858                    depth_or_array_layers: 1,
4859                },
4860                mip_level_count: 1,
4861                sample_count: 1,
4862                dimension: wgpu::TextureDimension::D2,
4863                format: wgpu::TextureFormat::R16Unorm,
4864                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4865                view_formats: &[],
4866            });
4867            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4868
4869            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4870                label: Some("p010 UV"),
4871                size: wgpu::Extent3d {
4872                    width: uv_w,
4873                    height: uv_h,
4874                    depth_or_array_layers: 1,
4875                },
4876                mip_level_count: 1,
4877                sample_count: 1,
4878                dimension: wgpu::TextureDimension::D2,
4879                format: wgpu::TextureFormat::Rg16Unorm,
4880                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4881                view_formats: &[],
4882            });
4883            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4884
4885            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4886                label: Some("p010 yuv transform"),
4887                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4888                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4889                mapped_at_creation: false,
4890            });
4891            self.queue
4892                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4893
4894            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "p010 bind");
4895
4896            let bytes = checked_image_bytes(w, h, 2)?
4897                .checked_add(checked_image_bytes(uv_w, uv_h, 4)?)
4898                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4899                .ok_or_else(|| anyhow::anyhow!("P010 image byte size overflow"))?;
4900            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4901
4902            self.images.insert(
4903                handle,
4904                ImageTex::Nv12 {
4905                    tex_y,
4906                    tex_uv,
4907                    binds,
4908                    yuv_buf,
4909                    w,
4910                    h,
4911                    pixel_format: PixelFormat::P010,
4912                    fourcc: Some(P010_FOURCC),
4913                    color_info,
4914                    external: false,
4915                    last_used_frame: self.frame_index,
4916                    bytes,
4917                },
4918            );
4919        } else {
4920            if let Some(ImageTex::Nv12 {
4921                yuv_buf,
4922                color_info: stored_color,
4923                last_used_frame,
4924                ..
4925            }) = self.images.get_mut(&handle)
4926            {
4927                *stored_color = color_info;
4928                *last_used_frame = self.frame_index;
4929                self.queue
4930                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4931            }
4932        }
4933
4934        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4935            Some(ImageTex::Nv12 {
4936                tex_y,
4937                tex_uv,
4938                binds,
4939                ..
4940            }) => (tex_y, tex_uv, binds),
4941            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
4942        };
4943
4944        self.queue.write_texture(
4945            wgpu::TexelCopyTextureInfo {
4946                texture: tex_y,
4947                mip_level: 0,
4948                origin: wgpu::Origin3d::ZERO,
4949                aspect: wgpu::TextureAspect::All,
4950            },
4951            &y[..y_expected],
4952            wgpu::TexelCopyBufferLayout {
4953                offset: 0,
4954                bytes_per_row: Some(
4955                    w.checked_mul(2)
4956                        .ok_or_else(|| anyhow::anyhow!("P010 row size overflow"))?,
4957                ),
4958                rows_per_image: Some(h),
4959            },
4960            wgpu::Extent3d {
4961                width: w,
4962                height: h,
4963                depth_or_array_layers: 1,
4964            },
4965        );
4966        self.queue.write_texture(
4967            wgpu::TexelCopyTextureInfo {
4968                texture: tex_uv,
4969                mip_level: 0,
4970                origin: wgpu::Origin3d::ZERO,
4971                aspect: wgpu::TextureAspect::All,
4972            },
4973            &uv[..uv_expected],
4974            wgpu::TexelCopyBufferLayout {
4975                offset: 0,
4976                bytes_per_row: Some(
4977                    uv_w.checked_mul(4)
4978                        .ok_or_else(|| anyhow::anyhow!("P010 UV row size overflow"))?,
4979                ),
4980                rows_per_image: Some(uv_h),
4981            },
4982            wgpu::Extent3d {
4983                width: uv_w,
4984                height: uv_h,
4985                depth_or_array_layers: 1,
4986            },
4987        );
4988
4989        self.evict_budget_excess();
4990        Ok(())
4991    }
4992
4993    #[cfg(target_os = "linux")]
4994    pub fn set_image_dmabuf(
4995        &mut self,
4996        handle: u64,
4997        w: u32,
4998        h: u32,
4999        fds: Vec<std::os::unix::io::OwnedFd>,
5000        modifier: u64,
5001        strides: Vec<u32>,
5002        offsets: Vec<u64>,
5003        color_info: ColorInfo,
5004    ) -> anyhow::Result<()> {
5005        self.set_image_dmabuf_fourcc(
5006            handle,
5007            w,
5008            h,
5009            fds,
5010            NV12_FOURCC,
5011            modifier,
5012            strides,
5013            offsets,
5014            color_info,
5015        )
5016    }
5017
5018    #[cfg(target_os = "linux")]
5019    pub fn set_image_dmabuf_fourcc(
5020        &mut self,
5021        handle: u64,
5022        w: u32,
5023        h: u32,
5024        fds: Vec<std::os::unix::io::OwnedFd>,
5025        fourcc: u32,
5026        modifier: u64,
5027        strides: Vec<u32>,
5028        offsets: Vec<u64>,
5029        color_info: ColorInfo,
5030    ) -> anyhow::Result<()> {
5031        validate_image_handle(handle)?;
5032        checked_image_bytes(w, h, 1)?;
5033        if w == 0
5034            || h == 0
5035            || w > self.device.limits().max_texture_dimension_2d
5036            || h > self.device.limits().max_texture_dimension_2d
5037        {
5038            anyhow::bail!("DMA-BUF dimensions are outside the device texture limit");
5039        }
5040        if modifier == u64::MAX {
5041            anyhow::bail!("DMA-BUF modifier is invalid");
5042        }
5043        if fds.len() != 2 || strides.len() != 2 || offsets.len() != 2 {
5044            anyhow::bail!(
5045                "DMA-BUF import requires two fds, strides, and offsets (got {}, {}, {})",
5046                fds.len(),
5047                strides.len(),
5048                offsets.len()
5049            );
5050        }
5051        let pixel_format = match fourcc {
5052            0x3231_564e | 0x4e56_3132 => PixelFormat::Nv12,
5053            0x3031_3050 | 0x5030_3130 => PixelFormat::P010,
5054            _ => anyhow::bail!("unsupported DMA-BUF fourcc 0x{fourcc:08x}"),
5055        };
5056        if !self
5057            .device
5058            .features()
5059            .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF)
5060        {
5061            anyhow::bail!("DMA-BUF import requires VULKAN_EXTERNAL_MEMORY_DMA_BUF");
5062        }
5063        if pixel_format == PixelFormat::P010
5064            && !self
5065                .device
5066                .features()
5067                .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
5068        {
5069            anyhow::bail!("P010 DMA-BUF requires TEXTURE_FORMAT_16BIT_NORM");
5070        }
5071        let bytes_per_pixel = if pixel_format == PixelFormat::P010 {
5072            2
5073        } else {
5074            1
5075        };
5076        let uv_w = w.div_ceil(2);
5077        let uv_h = h.div_ceil(2);
5078        let y_file_size =
5079            validate_dmabuf_plane(&fds[0], offsets[0], strides[0], w, h, bytes_per_pixel)?;
5080        let uv_file_size = validate_dmabuf_plane(
5081            &fds[1],
5082            offsets[1],
5083            strides[1],
5084            uv_w,
5085            uv_h,
5086            bytes_per_pixel * 2,
5087        )?;
5088        let yuv_raw = make_yuv_transform_raw(color_info, pixel_format);
5089        let (y_format, uv_format) = if pixel_format == PixelFormat::P010 {
5090            (
5091                wgpu::TextureFormat::R16Unorm,
5092                wgpu::TextureFormat::Rg16Unorm,
5093            )
5094        } else {
5095            (wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::Rg8Unorm)
5096        };
5097        let hal_y_desc = wgpu::hal::TextureDescriptor {
5098            label: Some("dmabuf y"),
5099            size: wgpu::Extent3d {
5100                width: w,
5101                height: h,
5102                depth_or_array_layers: 1,
5103            },
5104            mip_level_count: 1,
5105            sample_count: 1,
5106            dimension: wgpu::TextureDimension::D2,
5107            format: y_format,
5108            usage: wgpu::wgt::TextureUses::RESOURCE,
5109            memory_flags: wgpu::hal::MemoryFlags::empty(),
5110            view_formats: vec![],
5111        };
5112        let hal_uv_desc = wgpu::hal::TextureDescriptor {
5113            label: Some("dmabuf uv"),
5114            size: wgpu::Extent3d {
5115                width: uv_w,
5116                height: uv_h,
5117                depth_or_array_layers: 1,
5118            },
5119            mip_level_count: 1,
5120            sample_count: 1,
5121            dimension: wgpu::TextureDimension::D2,
5122            format: uv_format,
5123            usage: wgpu::wgt::TextureUses::RESOURCE,
5124            memory_flags: wgpu::hal::MemoryFlags::empty(),
5125            view_formats: vec![],
5126        };
5127        let wgpu_y_desc = wgpu::TextureDescriptor {
5128            label: Some("dmabuf y"),
5129            size: hal_y_desc.size,
5130            mip_level_count: 1,
5131            sample_count: 1,
5132            dimension: wgpu::TextureDimension::D2,
5133            format: y_format,
5134            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5135            view_formats: &[],
5136        };
5137        let wgpu_uv_desc = wgpu::TextureDescriptor {
5138            label: Some("dmabuf uv"),
5139            size: hal_uv_desc.size,
5140            mip_level_count: 1,
5141            sample_count: 1,
5142            dimension: wgpu::TextureDimension::D2,
5143            format: uv_format,
5144            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5145            view_formats: &[],
5146        };
5147
5148        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
5149            let hal_guard = self
5150                .device
5151                .as_hal::<wgpu::hal::vulkan::Api>()
5152                .ok_or_else(|| anyhow::anyhow!("device is not Vulkan"))?;
5153            let mut fds = fds.into_iter();
5154            let y_fd = fds.next().expect("validated fd count");
5155            let uv_fd = fds.next().expect("validated fd count");
5156            let y_texture = hal_guard.texture_from_dmabuf_fd(
5157                y_fd,
5158                &hal_y_desc,
5159                modifier,
5160                strides[0] as u64,
5161                offsets[0],
5162            );
5163            let y_texture =
5164                y_texture.map_err(|error| anyhow::anyhow!("import Y DMA-BUF: {error:?}"))?;
5165            let uv_texture = hal_guard.texture_from_dmabuf_fd(
5166                uv_fd,
5167                &hal_uv_desc,
5168                modifier,
5169                strides[1] as u64,
5170                offsets[1],
5171            );
5172            let uv_texture =
5173                uv_texture.map_err(|error| anyhow::anyhow!("import UV DMA-BUF: {error:?}"))?;
5174            drop(hal_guard);
5175            let tex_y = self
5176                .device
5177                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5178                    y_texture,
5179                    &wgpu_y_desc,
5180                    wgpu::wgt::TextureUses::UNINITIALIZED,
5181                );
5182            let tex_uv = self
5183                .device
5184                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5185                    uv_texture,
5186                    &wgpu_uv_desc,
5187                    wgpu::wgt::TextureUses::UNINITIALIZED,
5188                );
5189            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
5190            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5191            (tex_y, view_y, tex_uv, view_uv)
5192        };
5193
5194        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5195            label: Some("dmabuf yuv transform"),
5196            size: std::mem::size_of::<YuvTransformRaw>() as u64,
5197            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5198            mapped_at_creation: false,
5199        });
5200        self.queue
5201            .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5202        let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "dmabuf yuv bind");
5203        let bytes = y_file_size
5204            .checked_add(uv_file_size)
5205            .and_then(|value| value.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5206            .ok_or_else(|| anyhow::anyhow!("DMA-BUF byte size overflow"))?;
5207        self.evict_budget_excess();
5208        let replaced_bytes = self
5209            .images
5210            .get(&handle)
5211            .map(|image| match image {
5212                ImageTex::Rgba { bytes, .. }
5213                | ImageTex::Nv12 { bytes, .. }
5214                | ImageTex::User { bytes, .. } => *bytes,
5215            })
5216            .unwrap_or(0);
5217        let projected = self
5218            .image_bytes_total
5219            .saturating_sub(replaced_bytes)
5220            .checked_add(bytes)
5221            .ok_or_else(|| anyhow::anyhow!("DMA-BUF image budget overflow"))?;
5222        if projected > self.image_budget_bytes {
5223            anyhow::bail!("DMA-BUF image budget exceeded");
5224        }
5225        self.remove_image(handle);
5226        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5227        self.images.insert(
5228            handle,
5229            ImageTex::Nv12 {
5230                tex_y,
5231                tex_uv,
5232                binds,
5233                yuv_buf,
5234                w,
5235                h,
5236                pixel_format,
5237                fourcc: Some(canonical_fourcc(pixel_format)),
5238                color_info,
5239                external: true,
5240                last_used_frame: self.frame_index,
5241                bytes,
5242            },
5243        );
5244        self.evict_budget_excess();
5245        Ok(())
5246    }
5247
5248    pub fn image_fourcc(&self, handle: u64) -> Option<u32> {
5249        match self.images.get(&handle) {
5250            Some(ImageTex::Nv12 { fourcc, .. }) => *fourcc,
5251            _ => None,
5252        }
5253    }
5254
5255    pub fn remove_image(&mut self, handle: u64) {
5256        if let Some(img) = self.images.remove(&handle) {
5257            let b = match &img {
5258                ImageTex::Rgba { bytes, .. } => *bytes,
5259                ImageTex::Nv12 { bytes, .. } => *bytes,
5260                ImageTex::User { bytes, .. } => *bytes,
5261            };
5262            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5263        }
5264        if let Some(retained) = self.retained.remove(&handle) {
5265            self.retained_bytes_total = self
5266                .retained_bytes_total
5267                .saturating_sub(retained.rgba.len() as u64);
5268        }
5269    }
5270
5271    fn evict_image_gpu(&mut self, handle: u64) -> u64 {
5272        let Some(img) = self.images.remove(&handle) else {
5273            return 0;
5274        };
5275        let b = match &img {
5276            ImageTex::Rgba { bytes, .. } => *bytes,
5277            ImageTex::Nv12 { bytes, .. } => *bytes,
5278            ImageTex::User { bytes, .. } => *bytes,
5279        };
5280        self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5281        b
5282    }
5283
5284    fn revive_retained_image(&mut self, handle: u64) -> anyhow::Result<bool> {
5285        if self.images.contains_key(&handle) {
5286            return Ok(true);
5287        }
5288        let Some(r) = self.retained.get_mut(&handle) else {
5289            return Ok(false);
5290        };
5291        r.last_used_frame = self.frame_index;
5292        let r = r.clone();
5293        let (tex, binds) = self.create_rgba_tex(r.w, r.h, r.format);
5294
5295        self.queue.write_texture(
5296            wgpu::TexelCopyTextureInfo {
5297                texture: &tex,
5298                mip_level: 0,
5299                origin: wgpu::Origin3d::ZERO,
5300                aspect: wgpu::TextureAspect::All,
5301            },
5302            &r.rgba,
5303            wgpu::TexelCopyBufferLayout {
5304                offset: 0,
5305                bytes_per_row: Some(4 * r.w),
5306                rows_per_image: Some(r.h),
5307            },
5308            wgpu::Extent3d {
5309                width: r.w,
5310                height: r.h,
5311                depth_or_array_layers: 1,
5312            },
5313        );
5314
5315        let bytes = checked_image_bytes(r.w, r.h, 4)?;
5316        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5317        self.images.insert(
5318            handle,
5319            ImageTex::Rgba {
5320                tex,
5321                binds,
5322                w: r.w,
5323                h: r.h,
5324                format: r.format,
5325                last_used_frame: self.frame_index,
5326                bytes,
5327            },
5328        );
5329        Ok(true)
5330    }
5331
5332    fn resolve_image_for_draw(&mut self, handle: u64) -> Option<(u32, u32, bool)> {
5333        if let Some(retained) = self.retained.get_mut(&handle) {
5334            retained.last_used_frame = self.frame_index;
5335        }
5336        if let Some(t) = self.images.get_mut(&handle) {
5337            return match t {
5338                ImageTex::Rgba {
5339                    w,
5340                    h,
5341                    last_used_frame,
5342                    ..
5343                } => {
5344                    *last_used_frame = self.frame_index;
5345                    Some((*w, *h, false))
5346                }
5347                ImageTex::User {
5348                    w,
5349                    h,
5350                    last_used_frame,
5351                    ..
5352                } => {
5353                    *last_used_frame = self.frame_index;
5354                    Some((*w, *h, false))
5355                }
5356                ImageTex::Nv12 {
5357                    w,
5358                    h,
5359                    last_used_frame,
5360                    ..
5361                } => {
5362                    *last_used_frame = self.frame_index;
5363                    Some((*w, *h, true))
5364                }
5365            };
5366        }
5367        if self.revive_retained_image(handle).unwrap_or(false)
5368            && let Some(ImageTex::Rgba {
5369                w,
5370                h,
5371                last_used_frame,
5372                ..
5373            }) = self.images.get_mut(&handle)
5374        {
5375            *last_used_frame = self.frame_index;
5376            return Some((*w, *h, false));
5377        }
5378        None
5379    }
5380
5381    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
5382    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
5383        let handle = self.next_image_handle;
5384        self.next_image_handle += 1;
5385        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
5386            log::error!("Failed to register image: {e}");
5387        }
5388        handle
5389    }
5390
5391    /// Register raw RGBA8 pixels (`w * h * 4` bytes) as an image, returning
5392    /// its handle for `SceneNode::Image`. Used by CPU-rasterized overlays
5393    /// (e.g. subtitle bitmap layers) that have no encoded image bytes.
5394    /// Pass `srgb = true` for sRGB overlays composited over video.
5395    pub fn register_image_rgba8(&mut self, w: u32, h: u32, rgba: &[u8], srgb: bool) -> u64 {
5396        let handle = self.next_image_handle;
5397        self.next_image_handle += 1;
5398        if let Err(e) = self.set_image_rgba8(handle, w, h, rgba, srgb) {
5399            log::error!("Failed to register image: {e}");
5400        }
5401        handle
5402    }
5403
5404    /// Register an 8-bit coverage tile (`w * h` bytes, 0 = empty, 255 =
5405    /// fully covered) for `SceneNode::Coverage`, returning its handle.
5406    /// Coverage tiles are immutable: re-register on geometry change and
5407    /// `remove_coverage` handles you no longer emit (stale tiles also age
5408    /// out under the image eviction policy).
5409    pub fn register_coverage_a8(&mut self, w: u32, h: u32, coverage: &[u8]) -> u64 {
5410        if validate_texture_dimensions(&self.device, w, h).is_err() {
5411            log::error!("Coverage dimensions are outside the device limit");
5412            return 0;
5413        }
5414        let Ok(expected_bytes) = checked_image_bytes(w, h, 1) else {
5415            log::error!("Coverage dimensions overflow");
5416            return 0;
5417        };
5418        let Ok(expected) = usize::try_from(expected_bytes) else {
5419            log::error!("Coverage dimensions exceed addressable memory");
5420            return 0;
5421        };
5422        if coverage.len() < expected {
5423            log::error!("Coverage buffer too small: {} < {expected}", coverage.len());
5424            return 0;
5425        }
5426        let handle = self.next_coverage_handle;
5427        self.next_coverage_handle += 1;
5428
5429        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5430            label: Some("coverage tile a8"),
5431            size: wgpu::Extent3d {
5432                width: w,
5433                height: h,
5434                depth_or_array_layers: 1,
5435            },
5436            mip_level_count: 1,
5437            sample_count: 1,
5438            dimension: wgpu::TextureDimension::D2,
5439            format: wgpu::TextureFormat::R8Unorm,
5440            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5441            view_formats: &[],
5442        });
5443        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5444        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5445            label: Some("coverage bind a8"),
5446            layout: &self.image_bind_layout_rgba,
5447            entries: &[
5448                wgpu::BindGroupEntry {
5449                    binding: 0,
5450                    resource: wgpu::BindingResource::TextureView(&view),
5451                },
5452                wgpu::BindGroupEntry {
5453                    binding: 1,
5454                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5455                },
5456            ],
5457        });
5458        self.queue.write_texture(
5459            wgpu::TexelCopyTextureInfo {
5460                texture: &tex,
5461                mip_level: 0,
5462                origin: wgpu::Origin3d::ZERO,
5463                aspect: wgpu::TextureAspect::All,
5464            },
5465            &coverage[..expected],
5466            wgpu::TexelCopyBufferLayout {
5467                offset: 0,
5468                bytes_per_row: Some(w),
5469                rows_per_image: Some(h),
5470            },
5471            wgpu::Extent3d {
5472                width: w,
5473                height: h,
5474                depth_or_array_layers: 1,
5475            },
5476        );
5477        let bytes = checked_image_bytes(w, h, 1).unwrap_or(u64::MAX);
5478        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5479        self.coverages.insert(
5480            handle,
5481            CoverageTex {
5482                tex,
5483                bind,
5484                w,
5485                h,
5486                last_used_frame: self.frame_index,
5487                bytes,
5488            },
5489        );
5490        self.evict_budget_excess();
5491        handle
5492    }
5493
5494    /// Remove a coverage tile registered with [`register_coverage_a8`](Self::register_coverage_a8).
5495    pub fn remove_coverage(&mut self, handle: u64) {
5496        if let Some(tile) = self.coverages.remove(&handle) {
5497            self.image_bytes_total = self.image_bytes_total.saturating_sub(tile.bytes);
5498        }
5499    }
5500
5501    /// Tile dimensions, marking the handle used (keeps it alive under the
5502    /// eviction policy). Returns `None` for unknown handles.
5503    pub fn coverage_dimensions(&mut self, handle: u64) -> Option<(u32, u32)> {
5504        if let Some(tile) = self.coverages.get_mut(&handle) {
5505            tile.last_used_frame = self.frame_index;
5506            return Some((tile.w, tile.h));
5507        }
5508        None
5509    }
5510
5511    fn evict_retained_excess(&mut self) {
5512        let mut candidates: Vec<(u64, u64, u64)> = self
5513            .retained
5514            .iter()
5515            .map(|(handle, image)| (*handle, image.last_used_frame, image.rgba.len() as u64))
5516            .collect();
5517        candidates.sort_by_key(|candidate| candidate.1);
5518        for (handle, _last_used, bytes) in candidates {
5519            if self.retained.len() <= MAX_RETAINED_IMAGES
5520                && self.retained_bytes_total <= MAX_RETAINED_IMAGE_BYTES
5521            {
5522                break;
5523            }
5524            if let Some(image) = self.retained.remove(&handle) {
5525                self.retained_bytes_total = self
5526                    .retained_bytes_total
5527                    .saturating_sub(bytes.min(image.rgba.len() as u64));
5528            }
5529        }
5530    }
5531
5532    fn evict_unused_images(&mut self) {
5533        let now = self.frame_index;
5534        let evict_after = self.image_evict_after_frames;
5535
5536        // Time based eviction. Eviction only frees GPU memory: retained RGBA
5537        // sources stay so the image can be lazily re-uploaded when drawn again.
5538        let mut to_evict = Vec::new();
5539        for (h, t) in self.images.iter() {
5540            if !t.evictable() {
5541                continue;
5542            }
5543            let last = match t {
5544                ImageTex::Rgba {
5545                    last_used_frame, ..
5546                } => *last_used_frame,
5547                ImageTex::User {
5548                    last_used_frame, ..
5549                } => *last_used_frame,
5550                ImageTex::Nv12 {
5551                    last_used_frame, ..
5552                } => *last_used_frame,
5553            };
5554            if now.saturating_sub(last) > evict_after {
5555                to_evict.push(*h);
5556            }
5557        }
5558        for h in to_evict {
5559            if self.retained.contains_key(&h) {
5560                self.evict_image_gpu(h);
5561            } else {
5562                self.remove_image(h);
5563            }
5564        }
5565
5566        // Coverage tiles have no retained CPU copies: age-out removes them.
5567        let mut stale = Vec::new();
5568        for (h, t) in self.coverages.iter() {
5569            if now.saturating_sub(t.last_used_frame) > evict_after {
5570                stale.push(*h);
5571            }
5572        }
5573        for h in stale {
5574            self.remove_coverage(h);
5575        }
5576
5577        self.evict_budget_excess();
5578    }
5579
5580    fn next_cache_touch(&mut self) -> u64 {
5581        let touch = self.cache_touch_clock;
5582        self.cache_touch_clock = self.cache_touch_clock.wrapping_add(1);
5583        touch
5584    }
5585
5586    fn gpu_bytes_total(&self) -> u64 {
5587        self.image_bytes_total
5588            .saturating_add(self.layer_bytes_total)
5589            .saturating_add(self.blend_snapshot_bytes_total)
5590            .saturating_add(self.mesh_cache_bytes_total)
5591            .saturating_add(self.working_space_bytes)
5592            .saturating_add(self.surface_resolve_bytes)
5593            .saturating_add(self.msaa_bytes)
5594            .saturating_add(self.ws_msaa_bytes)
5595            .saturating_add(self.depth_stencil_bytes)
5596    }
5597
5598    fn budget_exceeded(&self) -> bool {
5599        self.image_bytes_total > self.image_budget_bytes
5600            || self.gpu_bytes_total() > self.gpu_budget_bytes
5601    }
5602
5603    fn evict_budget_excess(&mut self) {
5604        let image_over_budget = self.image_bytes_total > self.image_budget_bytes;
5605        let gpu_over_budget = self.gpu_bytes_total() > self.gpu_budget_bytes;
5606        if !image_over_budget && !gpu_over_budget {
5607            return;
5608        }
5609        let mut coverage_candidates: Vec<(u64, u64)> = self
5610            .coverages
5611            .iter()
5612            .map(|(handle, tile)| (*handle, tile.last_used_frame))
5613            .collect();
5614        coverage_candidates.sort_by_key(|candidate| candidate.1);
5615        let now = self.frame_index;
5616        for (handle, last_used) in coverage_candidates {
5617            if !self.budget_exceeded() {
5618                break;
5619            }
5620            if last_used == now {
5621                continue;
5622            }
5623            self.remove_coverage(handle);
5624        }
5625        if !self.budget_exceeded() {
5626            return;
5627        }
5628        // Collect (handle, last_used, bytes)
5629        let mut candidates: Vec<(u64, u64, u64)> = self
5630            .images
5631            .iter()
5632            .filter_map(|(h, t)| {
5633                if !t.evictable() {
5634                    return None;
5635                }
5636                let (last, bytes) = match t {
5637                    ImageTex::Rgba {
5638                        last_used_frame,
5639                        bytes,
5640                        ..
5641                    } => (*last_used_frame, *bytes),
5642                    ImageTex::User {
5643                        last_used_frame,
5644                        bytes,
5645                        ..
5646                    } => (*last_used_frame, *bytes),
5647                    ImageTex::Nv12 {
5648                        last_used_frame,
5649                        bytes,
5650                        ..
5651                    } => (*last_used_frame, *bytes),
5652                };
5653                Some((*h, last, bytes))
5654            })
5655            .collect();
5656
5657        // Sort by last_used ascending (LRU first)
5658        candidates.sort_by_key(|k| k.1);
5659
5660        for (h, last, _bytes) in candidates {
5661            if !self.budget_exceeded() {
5662                break;
5663            }
5664            if last == now {
5665                continue;
5666            }
5667            if self.retained.contains_key(&h) {
5668                self.evict_image_gpu(h);
5669            } else {
5670                self.remove_image(h);
5671            }
5672        }
5673        while self.gpu_bytes_total() > self.gpu_budget_bytes {
5674            if !self.evict_mesh_cache() {
5675                break;
5676            }
5677        }
5678        if self.gpu_bytes_total() > self.gpu_budget_bytes {
5679            log::warn!(
5680                "renderer GPU resource budget exceeded: {} > {}",
5681                self.gpu_bytes_total(),
5682                self.gpu_budget_bytes
5683            );
5684        }
5685    }
5686
5687    /// Set pixels per point (DPI scale) for callback `ScreenDescriptor` / `PaintCallbackInfo`.
5688    pub fn set_pixels_per_point(&mut self, ppp: f32) {
5689        if ppp.is_finite() {
5690            self.pixels_per_point = ppp.clamp(0.5, 8.0);
5691        }
5692    }
5693
5694    /// Enable or disable linear working-space rendering.
5695    /// When enabled, the scene is rendered into an Rgba16Float intermediate
5696    /// and a final full-screen pass applies the display OETF.
5697    pub fn set_working_space(&mut self, enabled: bool) {
5698        if enabled == self.working_space {
5699            return;
5700        }
5701        self.working_space = enabled;
5702        if enabled {
5703            self.ensure_display_pipeline();
5704            self.recreate_msaa_and_depth_stencil();
5705            self.recreate_working_space_texture();
5706        } else {
5707            self.ws_tex = None;
5708            self.ws_view = None;
5709            self.ws_bind = None;
5710            self.ws_msaa_tex = None;
5711            self.ws_msaa_view = None;
5712            self.surface_resolve_tex = None;
5713            self.surface_resolve_view = None;
5714            self.surface_resolve_bytes = 0;
5715            self.working_space_bytes = 0;
5716            self.recreate_msaa_and_depth_stencil();
5717        }
5718        self.evict_budget_excess();
5719    }
5720
5721    fn ensure_display_pipeline(&mut self) {
5722        if self.display_pipeline.is_some() {
5723            return;
5724        }
5725
5726        let layout = self
5727            .device
5728            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5729                label: Some("display transform layout"),
5730                entries: &[
5731                    wgpu::BindGroupLayoutEntry {
5732                        binding: 0,
5733                        visibility: wgpu::ShaderStages::FRAGMENT,
5734                        ty: wgpu::BindingType::Texture {
5735                            multisampled: false,
5736                            view_dimension: wgpu::TextureViewDimension::D2,
5737                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5738                        },
5739                        count: None,
5740                    },
5741                    wgpu::BindGroupLayoutEntry {
5742                        binding: 1,
5743                        visibility: wgpu::ShaderStages::FRAGMENT,
5744                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5745                        count: None,
5746                    },
5747                ],
5748            });
5749        self.display_layout = Some(layout);
5750
5751        let display_source: Cow<'static, str> = if self.output_format.is_srgb() {
5752            Cow::Borrowed(include_str!("shaders/display_transform_passthrough.wgsl"))
5753        } else {
5754            Cow::Borrowed(include_str!("shaders/display_transform.wgsl"))
5755        };
5756        let shader = self
5757            .device
5758            .create_shader_module(wgpu::ShaderModuleDescriptor {
5759                label: Some("display transform"),
5760                source: wgpu::ShaderSource::Wgsl(display_source),
5761            });
5762
5763        let pipeline_layout = self
5764            .device
5765            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
5766                label: Some("display transform pipeline layout"),
5767                bind_group_layouts: &[None, self.display_layout.as_ref()],
5768                immediate_size: 0,
5769            });
5770
5771        let pipeline = self
5772            .device
5773            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
5774                label: Some("display transform pipeline"),
5775                layout: Some(&pipeline_layout),
5776                vertex: wgpu::VertexState {
5777                    module: &shader,
5778                    entry_point: Some("vs_main"),
5779                    buffers: &[],
5780                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5781                },
5782                fragment: Some(wgpu::FragmentState {
5783                    module: &shader,
5784                    entry_point: Some("fs_main"),
5785                    targets: &[Some(wgpu::ColorTargetState {
5786                        format: self.output_format,
5787                        blend: None,
5788                        write_mask: wgpu::ColorWrites::ALL,
5789                    })],
5790                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5791                }),
5792                primitive: wgpu::PrimitiveState::default(),
5793                depth_stencil: None,
5794                multisample: wgpu::MultisampleState::default(),
5795                multiview_mask: None,
5796                cache: None,
5797            });
5798        self.display_pipeline = Some(pipeline);
5799    }
5800
5801    /// Resize the render target dimensions.
5802    ///
5803    /// Recreates MSAA, depth-stencil, and working-space textures to match the
5804    /// new size..
5805    pub fn resize(&mut self, width: u32, height: u32) {
5806        let (width, height) =
5807            clamp_output_size(width, height, self.device.limits().max_texture_dimension_2d);
5808        if self.output_width == width && self.output_height == height {
5809            return;
5810        }
5811        self.output_width = width;
5812        self.output_height = height;
5813        if width == 0 || height == 0 {
5814            self.msaa_tex = None;
5815            self.msaa_view = None;
5816            self.ws_msaa_tex = None;
5817            self.ws_msaa_view = None;
5818            self.surface_resolve_tex = None;
5819            self.surface_resolve_view = None;
5820            self.surface_resolve_bytes = 0;
5821            self.msaa_bytes = 0;
5822            self.ws_msaa_bytes = 0;
5823            self.depth_stencil_bytes = 0;
5824            self.working_space_bytes = 0;
5825            self.ws_tex = None;
5826            self.ws_view = None;
5827            self.ws_bind = None;
5828            self.evict_budget_excess();
5829            return;
5830        }
5831        self.recreate_msaa_and_depth_stencil();
5832        self.recreate_working_space_texture();
5833        self.evict_budget_excess();
5834    }
5835
5836    fn recreate_working_space_texture(&mut self) {
5837        if !self.working_space || self.output_width == 0 || self.output_height == 0 {
5838            return;
5839        }
5840        self.working_space_bytes = 0;
5841        let w = self
5842            .output_width
5843            .clamp(1, self.device.limits().max_texture_dimension_2d);
5844        let h = self
5845            .output_height
5846            .clamp(1, self.device.limits().max_texture_dimension_2d);
5847
5848        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5849            label: Some("working space"),
5850            size: wgpu::Extent3d {
5851                width: w,
5852                height: h,
5853                depth_or_array_layers: 1,
5854            },
5855            mip_level_count: 1,
5856            sample_count: 1,
5857            dimension: wgpu::TextureDimension::D2,
5858            format: wgpu::TextureFormat::Rgba16Float,
5859            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5860                | wgpu::TextureUsages::TEXTURE_BINDING
5861                | wgpu::TextureUsages::COPY_SRC
5862                | wgpu::TextureUsages::COPY_DST,
5863            view_formats: &[],
5864        });
5865        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5866
5867        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5868            label: Some("working space bind"),
5869            layout: self.display_layout.as_ref().unwrap(),
5870            entries: &[
5871                wgpu::BindGroupEntry {
5872                    binding: 0,
5873                    resource: wgpu::BindingResource::TextureView(&view),
5874                },
5875                wgpu::BindGroupEntry {
5876                    binding: 1,
5877                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5878                },
5879            ],
5880        });
5881
5882        self.working_space_bytes =
5883            texture_storage_bytes(wgpu::TextureFormat::Rgba16Float, w, h).unwrap_or(0);
5884        self.ws_tex = Some(tex);
5885        self.ws_view = Some(view);
5886        self.ws_bind = Some(bind);
5887    }
5888
5889    fn recreate_msaa_and_depth_stencil(&mut self) {
5890        if self.output_width == 0 || self.output_height == 0 {
5891            self.msaa_tex = None;
5892            self.msaa_view = None;
5893            self.ws_msaa_tex = None;
5894            self.ws_msaa_view = None;
5895            self.surface_resolve_tex = None;
5896            self.surface_resolve_view = None;
5897            self.surface_resolve_bytes = 0;
5898            self.msaa_bytes = 0;
5899            self.ws_msaa_bytes = 0;
5900            self.depth_stencil_bytes = 0;
5901            return;
5902        }
5903        self.msaa_bytes = 0;
5904        self.ws_msaa_bytes = 0;
5905        self.depth_stencil_bytes = 0;
5906        let aliased_surface = self.surface_format != self.output_format;
5907        let view_format_list = [self.output_format];
5908        let surface_view_formats: &[wgpu::TextureFormat] = if aliased_surface {
5909            &view_format_list
5910        } else {
5911            &[]
5912        };
5913        let surface_view = if aliased_surface {
5914            wgpu::TextureViewDescriptor {
5915                format: Some(self.output_format),
5916                ..Default::default()
5917            }
5918        } else {
5919            wgpu::TextureViewDescriptor::default()
5920        };
5921        if self.msaa_samples > 1 && !self.working_space {
5922            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5923                label: Some("msaa color"),
5924                size: wgpu::Extent3d {
5925                    width: self.output_width.max(1),
5926                    height: self.output_height.max(1),
5927                    depth_or_array_layers: 1,
5928                },
5929                mip_level_count: 1,
5930                sample_count: self.msaa_samples,
5931                dimension: wgpu::TextureDimension::D2,
5932                format: self.surface_format,
5933                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5934                view_formats: surface_view_formats,
5935            });
5936            let view = tex.create_view(&surface_view);
5937            self.msaa_bytes = texture_storage_bytes_with_samples(
5938                self.surface_format,
5939                self.output_width.max(1),
5940                self.output_height.max(1),
5941                self.msaa_samples,
5942            )
5943            .unwrap_or(0);
5944            self.msaa_tex = Some(tex);
5945            self.msaa_view = Some(view);
5946        } else {
5947            self.msaa_tex = None;
5948            self.msaa_view = None;
5949        }
5950        if self.working_space && self.working_space_msaa_samples > 1 {
5951            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5952                label: Some("working space msaa color"),
5953                size: wgpu::Extent3d {
5954                    width: self.output_width.max(1),
5955                    height: self.output_height.max(1),
5956                    depth_or_array_layers: 1,
5957                },
5958                mip_level_count: 1,
5959                sample_count: self.working_space_msaa_samples,
5960                dimension: wgpu::TextureDimension::D2,
5961                format: wgpu::TextureFormat::Rgba16Float,
5962                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5963                view_formats: &[],
5964            });
5965            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5966            self.ws_msaa_bytes = texture_storage_bytes_with_samples(
5967                wgpu::TextureFormat::Rgba16Float,
5968                self.output_width.max(1),
5969                self.output_height.max(1),
5970                self.working_space_msaa_samples,
5971            )
5972            .unwrap_or(0);
5973            self.ws_msaa_tex = Some(tex);
5974            self.ws_msaa_view = Some(view);
5975        } else {
5976            self.ws_msaa_tex = None;
5977            self.ws_msaa_view = None;
5978        }
5979        self.surface_resolve_tex = None;
5980        self.surface_resolve_view = None;
5981        self.surface_resolve_bytes = 0;
5982        if self.msaa_samples > 1 && !self.working_space {
5983            let width = self.output_width.max(1);
5984            let height = self.output_height.max(1);
5985            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5986                label: Some("surface resolve"),
5987                size: wgpu::Extent3d {
5988                    width,
5989                    height,
5990                    depth_or_array_layers: 1,
5991                },
5992                mip_level_count: 1,
5993                sample_count: 1,
5994                dimension: wgpu::TextureDimension::D2,
5995                format: self.surface_format,
5996                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5997                    | wgpu::TextureUsages::TEXTURE_BINDING
5998                    | wgpu::TextureUsages::COPY_SRC,
5999                view_formats: surface_view_formats,
6000            });
6001            let view = tex.create_view(&surface_view);
6002            self.surface_resolve_bytes =
6003                texture_storage_bytes(self.surface_format, width, height).unwrap_or(0);
6004            self.surface_resolve_tex = Some(tex);
6005            self.surface_resolve_view = Some(view);
6006        }
6007
6008        self.depth_stencil_bytes = texture_storage_bytes_with_samples(
6009            wgpu::TextureFormat::Depth24PlusStencil8,
6010            self.output_width.max(1),
6011            self.output_height.max(1),
6012            self.active_surface_msaa_samples(),
6013        )
6014        .unwrap_or(0);
6015        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
6016            label: Some("depth-stencil (stencil clips)"),
6017            size: wgpu::Extent3d {
6018                width: self.output_width.max(1),
6019                height: self.output_height.max(1),
6020                depth_or_array_layers: 1,
6021            },
6022            mip_level_count: 1,
6023            sample_count: self.active_surface_msaa_samples(),
6024            dimension: wgpu::TextureDimension::D2,
6025            format: wgpu::TextureFormat::Depth24PlusStencil8,
6026            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6027            view_formats: &[],
6028        });
6029        self.depth_stencil_view = self
6030            .depth_stencil_tex
6031            .create_view(&wgpu::TextureViewDescriptor::default());
6032    }
6033
6034    fn active_surface_msaa_samples(&self) -> u32 {
6035        if self.working_space {
6036            self.working_space_msaa_samples
6037        } else {
6038            self.msaa_samples
6039        }
6040    }
6041
6042    fn layer_target_format(&self) -> wgpu::TextureFormat {
6043        if self.working_space {
6044            wgpu::TextureFormat::Rgba16Float
6045        } else {
6046            self.output_format
6047        }
6048    }
6049
6050    fn get_or_create_layer(
6051        &mut self,
6052        layer_id: u32,
6053        width: u32,
6054        height: u32,
6055        rect: repose_core::Rect,
6056        transient: bool,
6057    ) -> bool {
6058        if width == 0
6059            || height == 0
6060            || width > self.device.limits().max_texture_dimension_2d
6061            || height > self.device.limits().max_texture_dimension_2d
6062        {
6063            return false;
6064        }
6065        let needs_alloc = match self.layer_pool.get(&layer_id) {
6066            Some(lt) => {
6067                lt.width != width || lt.height != height || lt.format != self.layer_target_format()
6068            }
6069            None => true,
6070        };
6071        if !needs_alloc {
6072            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
6073                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
6074            }
6075            return true;
6076        }
6077        let color_bytes =
6078            texture_storage_bytes(self.layer_target_format(), width.max(1), height.max(1))
6079                .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6080        let depth_bytes = checked_image_bytes(width.max(1), height.max(1), 4)
6081            .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6082        let bytes = color_bytes.saturating_add(depth_bytes);
6083        let old = self.layer_pool.get(&layer_id);
6084        let old_bytes = old.map_or(0, |layer| layer.bytes);
6085        let old_transient_bytes =
6086            old.map_or(0, |layer| if layer.transient { layer.bytes } else { 0 });
6087        let transient_count = self
6088            .layer_pool
6089            .values()
6090            .filter(|layer| layer.transient)
6091            .count();
6092        let transient_count = transient_count
6093            .saturating_sub(usize::from(old.is_some_and(|layer| layer.transient)))
6094            .saturating_add(usize::from(transient));
6095        let count = self.layer_pool.len() + usize::from(!self.layer_pool.contains_key(&layer_id));
6096        let total = self
6097            .layer_bytes_total
6098            .saturating_sub(old_bytes)
6099            .saturating_add(bytes);
6100        let projected_gpu = self
6101            .gpu_bytes_total()
6102            .saturating_sub(old_bytes)
6103            .saturating_add(bytes);
6104        let projected_transient = self
6105            .transient_layer_bytes_total
6106            .saturating_sub(old_transient_bytes)
6107            .saturating_add(if transient { bytes } else { 0 });
6108        if count > MAX_GRAPHICS_LAYERS
6109            || (transient
6110                && (transient_count > MAX_TRANSIENT_GRAPHICS_LAYERS
6111                    || projected_transient > MAX_TRANSIENT_GRAPHICS_LAYER_BYTES))
6112            || bytes > MAX_GRAPHICS_LAYER_BYTES
6113            || total > MAX_GRAPHICS_LAYER_BYTES
6114            || projected_gpu > self.gpu_budget_bytes
6115        {
6116            log::warn!("graphics layer budget exhausted; layer {layer_id} skipped");
6117            if let Some(old) = self.layer_pool.remove(&layer_id) {
6118                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6119                if old.transient {
6120                    self.transient_layer_bytes_total =
6121                        self.transient_layer_bytes_total.saturating_sub(old.bytes);
6122                }
6123            }
6124            return false;
6125        }
6126        if let Some(old) = self.layer_pool.remove(&layer_id) {
6127            self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6128            if old.transient {
6129                self.transient_layer_bytes_total =
6130                    self.transient_layer_bytes_total.saturating_sub(old.bytes);
6131            }
6132        }
6133        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6134            label: Some("graphics layer"),
6135            size: wgpu::Extent3d {
6136                width: width.max(1),
6137                height: height.max(1),
6138                depth_or_array_layers: 1,
6139            },
6140            mip_level_count: 1,
6141            sample_count: 1,
6142            dimension: wgpu::TextureDimension::D2,
6143            format: self.layer_target_format(),
6144            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6145                | wgpu::TextureUsages::TEXTURE_BINDING
6146                | wgpu::TextureUsages::COPY_SRC,
6147            view_formats: &[],
6148        });
6149        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6150        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6151            label: Some("layer bind"),
6152            layout: &self.image_bind_layout_rgba,
6153            entries: &[
6154                wgpu::BindGroupEntry {
6155                    binding: 0,
6156                    resource: wgpu::BindingResource::TextureView(&view),
6157                },
6158                wgpu::BindGroupEntry {
6159                    binding: 1,
6160                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
6161                },
6162            ],
6163        });
6164        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6165            label: Some("layer bind linear"),
6166            layout: &self.image_bind_layout_rgba,
6167            entries: &[
6168                wgpu::BindGroupEntry {
6169                    binding: 0,
6170                    resource: wgpu::BindingResource::TextureView(&view),
6171                },
6172                wgpu::BindGroupEntry {
6173                    binding: 1,
6174                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
6175                },
6176            ],
6177        });
6178        let depth_stencil_view = self.create_layer_depth_stencil(width, height);
6179        self.layer_pool.insert(
6180            layer_id,
6181            LayerTarget {
6182                texture: tex,
6183                view,
6184                bind,
6185                bind_linear,
6186                depth_stencil_view,
6187                width,
6188                height,
6189                format: self.layer_target_format(),
6190                bytes,
6191                rect_px: (rect.x, rect.y, rect.w, rect.h),
6192                transient,
6193            },
6194        );
6195        self.layer_bytes_total = self.layer_bytes_total.saturating_add(bytes);
6196        if transient {
6197            self.transient_layer_bytes_total =
6198                self.transient_layer_bytes_total.saturating_add(bytes);
6199        }
6200        true
6201    }
6202
6203    /// Allocate (or reuse) the padded scratch layer holding the horizontal half
6204    /// of a separable blur for `layer_id` in `slot`.
6205    ///
6206    /// Scratch ids are handed out from [`BLUR_SCRATCH_ID_BASE`] and remembered
6207    /// per `(layer_id, slot)` in [`Self::blur_scratch_ids`], so a stable layer
6208    /// reuses its allocation across frames while a content blur and a shadow
6209    /// on the same layer never share one. Allocation goes through the layer
6210    /// pool (as a transient layer) and therefore respects the layer budget;
6211    /// `None` means the caller should composite unblurred.
6212    fn acquire_blur_scratch(&mut self, layer_id: u32, slot: u32, pad: (f32, f32)) -> Option<u32> {
6213        let src = self.layer_pool.get(&layer_id)?;
6214        let width_f = (src.width as f32 + pad.0 * 2.0).ceil();
6215        let height_f = (src.height as f32 + pad.1 * 2.0).ceil();
6216        let max_dim = self.device.limits().max_texture_dimension_2d as f32;
6217        if !width_f.is_finite()
6218            || !height_f.is_finite()
6219            || width_f < 1.0
6220            || height_f < 1.0
6221            || width_f > max_dim
6222            || height_f > max_dim
6223        {
6224            log::warn!("blur scratch size {width_f}x{height_f} is invalid");
6225            return None;
6226        }
6227        let next = self.blur_scratch_next;
6228        let scratch_id = match self.blur_scratch_ids.get(&(layer_id, slot)) {
6229            Some(&id) => id,
6230            None => {
6231                let id = BLUR_SCRATCH_ID_BASE.wrapping_add(next);
6232                self.blur_scratch_next = next.wrapping_add(1);
6233                self.blur_scratch_ids.insert((layer_id, slot), id);
6234                id
6235            }
6236        };
6237        let allocated = self.get_or_create_layer(
6238            scratch_id,
6239            width_f as u32,
6240            height_f as u32,
6241            repose_core::Rect {
6242                x: 0.0,
6243                y: 0.0,
6244                w: width_f,
6245                h: height_f,
6246            },
6247            true,
6248        );
6249        allocated.then_some(scratch_id)
6250    }
6251
6252    /// Emit the horizontal half of a separable blur for `source_layer` into a
6253    /// padded scratch layer, and return that scratch's id so the caller can
6254    /// finish the vertical axis when it composites.
6255    ///
6256    /// The scratch pass is spliced between the pass being built and the pass the
6257    /// caller continues in, so ordering stays: everything drawn so far, the
6258    /// horizontal blur, then the vertical composite. `None` means no scratch
6259    /// could be allocated and the caller should composite without the blur.
6260    ///
6261    /// `slot` picks the scratch this blur owns, so concurrent blurs of one
6262    /// layer (content and shadow) get their own texture. The horizontal
6263    /// instance carries no tint: the scratch holds the raw blurred signal and
6264    /// the caller applies the group alpha / shadow colour on the vertical pass,
6265    /// which would otherwise square the alpha across the two axes.
6266    ///
6267    /// `alpha_only` selects the shadow kernel (alpha, premultiplied by the
6268    /// tint the caller applies vertically) over the full-RGBA kernel used by
6269    /// `Modifier::blur`.
6270    #[allow(clippy::too_many_arguments)]
6271    fn emit_separable_blur(
6272        &mut self,
6273        source_layer: u32,
6274        slot: u32,
6275        sigma: [f32; 2],
6276        pad: (f32, f32),
6277        edge_mode: u32,
6278        alpha_only: bool,
6279        shape: [f32; 4],
6280        passes: &mut Vec<Pass>,
6281        current_pass: &mut Pass,
6282        next_pass_id: &mut u64,
6283        encoder: &mut wgpu::CommandEncoder,
6284    ) -> Option<u32> {
6285        let scratch_id = self.acquire_blur_scratch(source_layer, slot, pad)?;
6286        let (scratch_w, scratch_h, source_w, source_h) = {
6287            let scratch = self.layer_pool.get(&scratch_id)?;
6288            let source = self.layer_pool.get(&source_layer)?;
6289            (
6290                scratch.width,
6291                scratch.height,
6292                source.width as f32,
6293                source.height as f32,
6294            )
6295        };
6296        // The quad covers the whole scratch, and scratch texel (x, y) samples
6297        // the source at (x - pad_x, y - pad_y). Padding texels therefore land
6298        // outside the source's 0..1, and `edge_mode` decides whether that
6299        // contributes nothing (decal, correct for a shadow) or clamps to the
6300        // edge texel (the `Modifier::blur` clamp edge treatment).
6301        let uv = [
6302            -pad.0 / source_w,
6303            -pad.1 / source_h,
6304            1.0 + pad.0 / source_w,
6305            1.0 + pad.1 / source_h,
6306        ];
6307        let ndc = [-1.0, -1.0, 2.0, 2.0];
6308        let inst = BlurInstance {
6309            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
6310            uv,
6311            color: [1.0, 1.0, 1.0, 1.0],
6312            sigma_px: sigma,
6313            fwd_mat: [1.0, 0.0, 0.0, 1.0],
6314            axis: 0,
6315            edge_mode,
6316            shape,
6317        };
6318        let cmd = match self.upload_blur_instance(&inst, encoder) {
6319            Some(off) if alpha_only => Cmd::CompositeShadow {
6320                off,
6321                cnt: 1,
6322                layer_id: source_layer,
6323            },
6324            Some(off) => Cmd::CompositeBlur {
6325                off,
6326                cnt: 1,
6327                layer_id: source_layer,
6328            },
6329            None => return None,
6330        };
6331        let target = current_pass.target;
6332        let scissor = current_pass.active_scissor;
6333        let resumed_pass_id = *next_pass_id;
6334        *next_pass_id += 1;
6335        let saved = std::mem::replace(
6336            current_pass,
6337            Pass {
6338                id: resumed_pass_id,
6339                target,
6340                initial_scissor: scissor.unwrap_or((0, 0, 1, 1)),
6341                active_scissor: scissor,
6342                clear_color: None,
6343                cmds: Vec::new(),
6344            },
6345        );
6346        passes.push(saved);
6347        let scratch_pass_id = *next_pass_id;
6348        *next_pass_id += 1;
6349        passes.push(Pass {
6350            id: scratch_pass_id,
6351            target: PassTarget::Layer(scratch_id),
6352            initial_scissor: (0, 0, scratch_w, scratch_h),
6353            active_scissor: Some((0, 0, scratch_w, scratch_h)),
6354            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
6355            cmds: vec![cmd],
6356        });
6357        Some(scratch_id)
6358    }
6359
6360    fn upload_blur_instance(
6361        &mut self,
6362        inst: &BlurInstance,
6363        encoder: &mut wgpu::CommandEncoder,
6364    ) -> Option<u64> {
6365        self.blur_ring
6366            .grow_to_fit(
6367                &self.device,
6368                encoder,
6369                std::mem::size_of::<BlurInstance>() as u64,
6370            )
6371            .ok()?;
6372        self.blur_ring
6373            .alloc_write(&self.queue, bytemuck::bytes_of(inst))
6374            .ok()
6375    }
6376    fn create_layer_depth_stencil(&self, width: u32, height: u32) -> wgpu::TextureView {
6377        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6378            label: Some("layer depth-stencil"),
6379            size: wgpu::Extent3d {
6380                width: width.max(1),
6381                height: height.max(1),
6382                depth_or_array_layers: 1,
6383            },
6384            mip_level_count: 1,
6385            sample_count: 1,
6386            dimension: wgpu::TextureDimension::D2,
6387            format: wgpu::TextureFormat::Depth24PlusStencil8,
6388            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6389            view_formats: &[],
6390        });
6391        tex.create_view(&wgpu::TextureViewDescriptor::default())
6392    }
6393
6394    fn ensure_globals_staging(&mut self, pass_count: usize) -> bool {
6395        let size = std::mem::size_of::<Globals>() as u64;
6396        let needed = match (pass_count.max(1) as u64).checked_mul(size) {
6397            Some(needed) => needed,
6398            None => return false,
6399        };
6400        if needed <= self.globals_staging_cap {
6401            return true;
6402        }
6403        let cap = needed
6404            .next_power_of_two()
6405            .min(self.device.limits().max_buffer_size);
6406        if cap < needed {
6407            return false;
6408        }
6409        self.globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
6410            label: Some("globals staging"),
6411            size: cap,
6412            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
6413            mapped_at_creation: false,
6414        });
6415        self.globals_staging_cap = cap;
6416        true
6417    }
6418
6419    fn reset_atlas_storage(&mut self) {
6420        self.atlas_mask = init_atlas_mask(&self.device);
6421        self.atlas_color = init_atlas_color(&self.device);
6422        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6423            label: Some("atlas mask bind"),
6424            layout: &self.text_bind_layout,
6425            entries: &[
6426                wgpu::BindGroupEntry {
6427                    binding: 0,
6428                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6429                },
6430                wgpu::BindGroupEntry {
6431                    binding: 1,
6432                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6433                },
6434            ],
6435        });
6436        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6437            label: Some("atlas color bind"),
6438            layout: &self.text_bind_layout,
6439            entries: &[
6440                wgpu::BindGroupEntry {
6441                    binding: 0,
6442                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6443                },
6444                wgpu::BindGroupEntry {
6445                    binding: 1,
6446                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6447                },
6448            ],
6449        });
6450        self.atlas_mask_pending = AtlasPending::default();
6451        self.atlas_color_pending = AtlasPending::default();
6452        self.atlas_mask_deferred.clear();
6453        self.atlas_color_deferred.clear();
6454    }
6455
6456    /// Apply atlas growth and compaction scheduled during the previous frame.
6457    /// Called from `begin_frame`, before any glyph is batched: both operations
6458    /// re-pack the atlas, so running them mid-frame would leave already-batched
6459    /// instances pointing at the old packing.
6460    fn apply_atlas_pending(&mut self) {
6461        if self.atlas_mask_pending.grow {
6462            self.atlas_mask_pending.grow = false;
6463            let size = (self.atlas_mask.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6464            self.repack_mask(size);
6465        }
6466        if self.atlas_color_pending.grow {
6467            self.atlas_color_pending.grow = false;
6468            let size = (self.atlas_color.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6469            self.repack_color(size);
6470        }
6471        if self.frame_index.wrapping_sub(self.atlas_last_compact_frame)
6472            < ATLAS_COMPACT_MIN_FRAME_INTERVAL
6473        {
6474            return;
6475        }
6476        // Stay armed when a compaction finds nothing evictable, so the deferred
6477        // glyphs it would rescue get another chance next frame.
6478        if self.atlas_mask_pending.compact && self.compact_atlas_mask() {
6479            self.atlas_mask_pending.compact = false;
6480        }
6481        if self.atlas_color_pending.compact && self.compact_atlas_color() {
6482            self.atlas_color_pending.compact = false;
6483        }
6484    }
6485
6486    /// Drop the least recently used mask glyphs and re-pack at the same size.
6487    /// A shelf packer cannot hand back an individual cell, so reclaiming space
6488    /// means evicting cold entries and rebuilding around the warm ones. Returns
6489    /// false when there was nothing to evict.
6490    fn compact_atlas_mask(&mut self) -> bool {
6491        let target = self.atlas_mask.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6492        if !evict_atlas_lru(
6493            &mut self.atlas_mask.map,
6494            &mut self.atlas_mask.used_area,
6495            self.frame_index,
6496            target,
6497        ) {
6498            return false;
6499        }
6500        let size = self.atlas_mask.size;
6501        self.repack_mask(size);
6502        self.atlas_last_compact_frame = self.frame_index;
6503        true
6504    }
6505
6506    fn compact_atlas_color(&mut self) -> bool {
6507        let target = self.atlas_color.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6508        if !evict_atlas_lru(
6509            &mut self.atlas_color.map,
6510            &mut self.atlas_color.used_area,
6511            self.frame_index,
6512            target,
6513        ) {
6514            return false;
6515        }
6516        let size = self.atlas_color.size;
6517        self.repack_color(size);
6518        self.atlas_last_compact_frame = self.frame_index;
6519        true
6520    }
6521
6522    /// Re-pack the mask atlas into a `new_size` texture and re-upload every
6523    /// surviving entry, preserving its recency so a repack does not make warm
6524    /// glyphs look cold.
6525    fn repack_mask(&mut self, new_size: u32) {
6526        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6527            label: Some("glyph atlas A8 (repacked)"),
6528            size: wgpu::Extent3d {
6529                width: new_size,
6530                height: new_size,
6531                depth_or_array_layers: 1,
6532            },
6533            mip_level_count: 1,
6534            sample_count: 1,
6535            dimension: wgpu::TextureDimension::D2,
6536            format: wgpu::TextureFormat::R8Unorm,
6537            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6538            view_formats: &[],
6539        });
6540        self.atlas_mask.tex = tex;
6541        self.atlas_mask.view = self
6542            .atlas_mask
6543            .tex
6544            .create_view(&wgpu::TextureViewDescriptor::default());
6545        self.atlas_mask.size = new_size;
6546        self.atlas_mask.next_x = 1;
6547        self.atlas_mask.next_y = 1;
6548        self.atlas_mask.row_h = 0;
6549        self.atlas_mask.used_area = 0;
6550        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6551            label: Some("atlas mask bind"),
6552            layout: &self.text_bind_layout,
6553            entries: &[
6554                wgpu::BindGroupEntry {
6555                    binding: 0,
6556                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6557                },
6558                wgpu::BindGroupEntry {
6559                    binding: 1,
6560                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6561                },
6562            ],
6563        });
6564        let survivors: Vec<_> = self.atlas_mask.map.drain().collect();
6565        self.atlas_mask_deferred.clear();
6566        for (key, previous) in survivors {
6567            let _ = self.upload_glyph_mask(key.clone(), f32::from_bits(key.font_size_bits));
6568            if let Some(info) = self.atlas_mask.map.get_mut(&key) {
6569                info.last_touch = previous.last_touch;
6570                info.last_used_frame = previous.last_used_frame;
6571            }
6572        }
6573    }
6574
6575    /// Re-pack the color atlas into a `new_size` texture. See [`Self::repack_mask`].
6576    fn repack_color(&mut self, new_size: u32) {
6577        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6578            label: Some("glyph atlas RGBA (repacked)"),
6579            size: wgpu::Extent3d {
6580                width: new_size,
6581                height: new_size,
6582                depth_or_array_layers: 1,
6583            },
6584            mip_level_count: 1,
6585            sample_count: 1,
6586            dimension: wgpu::TextureDimension::D2,
6587            format: wgpu::TextureFormat::Rgba8UnormSrgb,
6588            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6589            view_formats: &[],
6590        });
6591        self.atlas_color.tex = tex;
6592        self.atlas_color.view = self
6593            .atlas_color
6594            .tex
6595            .create_view(&wgpu::TextureViewDescriptor::default());
6596        self.atlas_color.size = new_size;
6597        self.atlas_color.next_x = 1;
6598        self.atlas_color.next_y = 1;
6599        self.atlas_color.row_h = 0;
6600        self.atlas_color.used_area = 0;
6601        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6602            label: Some("atlas color bind"),
6603            layout: &self.text_bind_layout,
6604            entries: &[
6605                wgpu::BindGroupEntry {
6606                    binding: 0,
6607                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6608                },
6609                wgpu::BindGroupEntry {
6610                    binding: 1,
6611                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6612                },
6613            ],
6614        });
6615        let survivors: Vec<_> = self.atlas_color.map.drain().collect();
6616        self.atlas_color_deferred.clear();
6617        for (key, previous) in survivors {
6618            let _ = self.upload_glyph_color(key.clone(), f32::from_bits(key.font_size_bits));
6619            if let Some(info) = self.atlas_color.map.get_mut(&key) {
6620                info.last_touch = previous.last_touch;
6621                info.last_used_frame = previous.last_used_frame;
6622            }
6623        }
6624    }
6625
6626    fn sync_text_caches(&mut self) {
6627        let generation = repose_text::font_generation();
6628        if generation == self.atlas_generation {
6629            return;
6630        }
6631        self.atlas_generation = generation;
6632        self.reset_atlas_storage();
6633        self.atlas_mask_failures.clear();
6634        self.atlas_color_failures.clear();
6635        self.glyph_outline_cache.clear();
6636        self.legacy_shaped_text_cache.clear();
6637        self.slug_draw_cache.clear();
6638        self.slug_draw_cache_bytes_total = 0;
6639        self.slug_cache = slug::GlyphSlugCache::new();
6640    }
6641
6642    fn shape_text_cached(
6643        &mut self,
6644        text: &Arc<str>,
6645        px: f32,
6646        line_height_ratio: f32,
6647        font_family: Option<&'static str>,
6648        font_weight: u16,
6649        font_style: u8,
6650        letter_spacing: f32,
6651        variation: Option<&Arc<str>>,
6652    ) -> Arc<repose_text::SharedShapedText> {
6653        repose_text::shape_line_cached(
6654            text.as_ref(),
6655            px,
6656            line_height_ratio,
6657            font_family,
6658            font_weight,
6659            font_style,
6660            letter_spacing,
6661            variation.map(AsRef::as_ref),
6662        )
6663    }
6664
6665    fn legacy_shape_text_cached(
6666        &mut self,
6667        text: &Arc<str>,
6668        px: f32,
6669        line_height_ratio: f32,
6670        font_family: Option<&'static str>,
6671        font_weight: u16,
6672        font_style: u8,
6673        letter_spacing: f32,
6674        variation: Option<&Arc<str>>,
6675    ) -> Arc<[repose_text::ShapedGlyph]> {
6676        let key = TextShapeKey {
6677            text: text.clone(),
6678            px_bits: px.to_bits(),
6679            line_height_bits: line_height_ratio.to_bits(),
6680            font_family,
6681            font_weight,
6682            font_style,
6683            letter_spacing_bits: letter_spacing.to_bits(),
6684            variation: variation.cloned(),
6685            font_generation: self.atlas_generation,
6686        };
6687        if let Some(entry) = self.legacy_shaped_text_cache.get_mut(&key) {
6688            entry.last_used_frame = self.frame_index;
6689            return entry.glyphs.clone();
6690        }
6691        let glyphs: Arc<[repose_text::ShapedGlyph]> = Arc::from(repose_text::shape_line(
6692            text.as_ref(),
6693            px,
6694            line_height_ratio,
6695            font_family,
6696            font_weight,
6697            font_style,
6698            letter_spacing,
6699            variation.map(AsRef::as_ref),
6700        ));
6701        self.legacy_shaped_text_cache.insert(
6702            key,
6703            TextShapeCacheEntry {
6704                glyphs: glyphs.clone(),
6705                last_used_frame: self.frame_index,
6706            },
6707        );
6708        while self.legacy_shaped_text_cache.len() > MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES {
6709            let Some(victim) = self
6710                .legacy_shaped_text_cache
6711                .iter()
6712                .min_by_key(|(_, entry)| entry.last_used_frame)
6713                .map(|(key, _)| key.clone())
6714            else {
6715                break;
6716            };
6717            self.legacy_shaped_text_cache.remove(&victim);
6718        }
6719        glyphs
6720    }
6721
6722    fn slug_draw_key(
6723        cache_key: &repose_text::CacheKey,
6724        fill: bool,
6725        stroke_key: Option<&slug::StrokeTessKey>,
6726        transform: &Transform,
6727        origin: (f32, f32),
6728        px: f32,
6729        target: (f32, f32),
6730        color: [f32; 4],
6731    ) -> SlugDrawKey {
6732        let linear = transform.linear();
6733        SlugDrawKey {
6734            cache_key: cache_key.clone(),
6735            fill,
6736            stroke: stroke_key.cloned(),
6737            transform: [
6738                linear[0].to_bits(),
6739                linear[1].to_bits(),
6740                linear[2].to_bits(),
6741                linear[3].to_bits(),
6742                transform.scale_x.to_bits(),
6743                transform.scale_y.to_bits(),
6744                transform.translate_x.to_bits(),
6745                transform.translate_y.to_bits(),
6746                transform.rotate.to_bits(),
6747                transform.shear_x.to_bits(),
6748                transform.shear_y.to_bits(),
6749            ],
6750            origin: [origin.0.to_bits(), origin.1.to_bits()],
6751            px_bits: px.to_bits(),
6752            target: [target.0.to_bits(), target.1.to_bits()],
6753            color: color.map(f32::to_bits),
6754        }
6755    }
6756
6757    fn evict_slug_draw_cache(&mut self) -> bool {
6758        let victim = self
6759            .slug_draw_cache
6760            .iter()
6761            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
6762            .min_by_key(|(_, entry)| entry.last_touch_frame)
6763            .map(|(key, _)| key.clone());
6764        let Some(victim) = victim else {
6765            return false;
6766        };
6767        let Some(entry) = self.slug_draw_cache.remove(&victim) else {
6768            return false;
6769        };
6770        self.slug_draw_cache_bytes_total =
6771            self.slug_draw_cache_bytes_total.saturating_sub(entry.bytes);
6772        true
6773    }
6774
6775    fn reserve_slug_draw_cache(&mut self, bytes: u64) -> bool {
6776        while self.slug_draw_cache.len() >= MAX_SLUG_DRAW_CACHE_ENTRIES
6777            || self.slug_draw_cache_bytes_total.saturating_add(bytes) > MAX_SLUG_DRAW_CACHE_BYTES
6778        {
6779            if !self.evict_slug_draw_cache() {
6780                return false;
6781            }
6782        }
6783        true
6784    }
6785
6786    fn get_cached_slug_vertices(&mut self, key: &SlugDrawKey) -> Option<Arc<[slug::TessVertex]>> {
6787        let touch = self.next_cache_touch();
6788        self.slug_draw_cache.get_mut(key).map(|entry| {
6789            entry.last_used_frame = self.frame_index;
6790            entry.last_touch_frame = touch;
6791            entry.vertices.clone()
6792        })
6793    }
6794
6795    fn cached_slug_vertices(
6796        &mut self,
6797        key: SlugDrawKey,
6798        vertices: Vec<slug::TessVertex>,
6799    ) -> Arc<[slug::TessVertex]> {
6800        let touch = self.next_cache_touch();
6801        if let Some(entry) = self.slug_draw_cache.get_mut(&key) {
6802            entry.last_used_frame = self.frame_index;
6803            entry.last_touch_frame = touch;
6804            return entry.vertices.clone();
6805        }
6806        let bytes = slug_draw_cache_entry_bytes(vertices.len());
6807        if bytes > MAX_SLUG_DRAW_CACHE_BYTES || !self.reserve_slug_draw_cache(bytes) {
6808            return Arc::from(vertices);
6809        }
6810        let touch = self.next_cache_touch();
6811        let vertices: Arc<[slug::TessVertex]> = Arc::from(vertices);
6812        self.slug_draw_cache.insert(
6813            key,
6814            SlugDrawCacheEntry {
6815                vertices: vertices.clone(),
6816                bytes,
6817                last_used_frame: self.frame_index,
6818                last_touch_frame: touch,
6819            },
6820        );
6821        self.slug_draw_cache_bytes_total = self.slug_draw_cache_bytes_total.saturating_add(bytes);
6822        vertices
6823    }
6824
6825    fn cached_glyph_outline(
6826        &mut self,
6827        cache_key: &repose_text::CacheKey,
6828    ) -> Option<Arc<[repose_text::Command]>> {
6829        let touch = self.next_cache_touch();
6830        if let Some(entry) = self.glyph_outline_cache.get_mut(cache_key) {
6831            entry.last_touch = touch;
6832            return entry.commands.clone();
6833        }
6834        let commands = repose_text::extract_outline_commands(cache_key).map(Arc::from);
6835        if self.glyph_outline_cache.len() >= MAX_GLYPH_OUTLINE_CACHE_ENTRIES {
6836            self.trim_glyph_outline_cache();
6837        }
6838        self.glyph_outline_cache.insert(
6839            cache_key.clone(),
6840            GlyphOutlineEntry {
6841                commands: commands.clone(),
6842                last_touch: touch,
6843            },
6844        );
6845        commands
6846    }
6847
6848    /// Drop the coldest outlines so a working set larger than the cap degrades
6849    /// into steady churn instead of a full rebuild on every miss.
6850    fn trim_glyph_outline_cache(&mut self) {
6851        let target = (MAX_GLYPH_OUTLINE_CACHE_ENTRIES / ATLAS_COMPACT_KEEP_DEN as usize)
6852            * ATLAS_COMPACT_KEEP_NUM as usize;
6853        let mut victims: Vec<(u64, repose_text::CacheKey)> = self
6854            .glyph_outline_cache
6855            .iter()
6856            .map(|(key, entry)| (entry.last_touch, key.clone()))
6857            .collect();
6858        victims.sort_unstable_by_key(|victim| victim.0);
6859        for (_, key) in victims {
6860            if self.glyph_outline_cache.len() <= target {
6861                break;
6862            }
6863            self.glyph_outline_cache.remove(&key);
6864        }
6865    }
6866
6867    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
6868        self.atlas_mask_bind.clone()
6869    }
6870
6871    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
6872        self.atlas_color_bind.clone()
6873    }
6874
6875    fn mark_atlas_mask_failure(&mut self, key: repose_text::CacheKey) {
6876        if self.atlas_mask_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6877            self.atlas_mask_failures.clear();
6878        }
6879        self.atlas_mask_failures.insert(key);
6880    }
6881
6882    fn mark_atlas_color_failure(&mut self, key: repose_text::CacheKey) {
6883        if self.atlas_color_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6884            self.atlas_color_failures.clear();
6885        }
6886        self.atlas_color_failures.insert(key);
6887    }
6888
6889    fn upload_glyph_mask(
6890        &mut self,
6891        cache_key: repose_text::CacheKey,
6892        px: f32,
6893    ) -> Option<GlyphInfo> {
6894        let px = quantize_glyph_px(px);
6895        let keyp = repose_text::CacheKey {
6896            font_size_bits: px.to_bits(),
6897            ..cache_key
6898        };
6899        let touch = self.next_cache_touch();
6900        if let Some(info) = self.atlas_mask.map.get_mut(&keyp) {
6901            info.last_touch = touch;
6902            info.last_used_frame = self.frame_index;
6903            return Some(*info);
6904        }
6905        if self.atlas_mask_failures.contains(&keyp) || self.atlas_mask_deferred.contains(&keyp) {
6906            return None;
6907        }
6908
6909        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
6910            self.mark_atlas_mask_failure(keyp);
6911            return None;
6912        };
6913        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
6914            self.mark_atlas_mask_failure(keyp);
6915            return None;
6916        }
6917        let Some(coverage) = swash_to_a8_coverage(gb.content, &gb.data) else {
6918            self.mark_atlas_mask_failure(keyp);
6919            return None;
6920        };
6921
6922        let w = gb.w.max(1);
6923        let h = gb.h.max(1);
6924        let expected = checked_image_bytes_usize(w, h, 1).ok()?;
6925        if coverage.len() < expected {
6926            self.mark_atlas_mask_failure(keyp);
6927            return None;
6928        }
6929        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
6930            self.mark_atlas_mask_failure(keyp);
6931            return None;
6932        }
6933        if !self.alloc_or_schedule_mask(w, h) {
6934            defer_atlas_glyph(&mut self.atlas_mask_deferred, keyp);
6935            return None;
6936        }
6937        let x = self.atlas_mask.next_x;
6938        let y = self.atlas_mask.next_y;
6939        self.atlas_mask.next_x += w + 1;
6940        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
6941
6942        let layout = wgpu::TexelCopyBufferLayout {
6943            offset: 0,
6944            bytes_per_row: Some(w),
6945            rows_per_image: Some(h),
6946        };
6947        let size = wgpu::Extent3d {
6948            width: w,
6949            height: h,
6950            depth_or_array_layers: 1,
6951        };
6952        self.queue.write_texture(
6953            wgpu::TexelCopyTextureInfoBase {
6954                texture: &self.atlas_mask.tex,
6955                mip_level: 0,
6956                origin: wgpu::Origin3d { x, y, z: 0 },
6957                aspect: wgpu::TextureAspect::All,
6958            },
6959            &coverage,
6960            layout,
6961            size,
6962        );
6963
6964        let info = GlyphInfo {
6965            u0: x as f32 / self.atlas_mask.size as f32,
6966            v0: y as f32 / self.atlas_mask.size as f32,
6967            u1: (x + w) as f32 / self.atlas_mask.size as f32,
6968            v1: (y + h) as f32 / self.atlas_mask.size as f32,
6969            w: w as f32,
6970            h: h as f32,
6971            last_touch: touch,
6972            last_used_frame: self.frame_index,
6973        };
6974        self.atlas_mask.used_area = self
6975            .atlas_mask
6976            .used_area
6977            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
6978        self.atlas_mask.map.insert(keyp, info);
6979        Some(info)
6980    }
6981
6982    fn upload_glyph_color(
6983        &mut self,
6984        cache_key: repose_text::CacheKey,
6985        px: f32,
6986    ) -> Option<GlyphInfo> {
6987        let px = quantize_glyph_px(px);
6988        let keyp = repose_text::CacheKey {
6989            font_size_bits: px.to_bits(),
6990            ..cache_key
6991        };
6992        let touch = self.next_cache_touch();
6993        if let Some(info) = self.atlas_color.map.get_mut(&keyp) {
6994            info.last_touch = touch;
6995            info.last_used_frame = self.frame_index;
6996            return Some(*info);
6997        }
6998        if self.atlas_color_failures.contains(&keyp) || self.atlas_color_deferred.contains(&keyp) {
6999            return None;
7000        }
7001        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7002            self.mark_atlas_color_failure(keyp);
7003            return None;
7004        };
7005        if !matches!(gb.content, repose_text::SwashContent::Color) {
7006            self.mark_atlas_color_failure(keyp);
7007            return None;
7008        }
7009        let w = gb.w.max(1);
7010        let h = gb.h.max(1);
7011        let expected = checked_image_bytes_usize(w, h, 4).ok()?;
7012        if gb.data.len() < expected {
7013            self.mark_atlas_color_failure(keyp);
7014            return None;
7015        }
7016        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7017            self.mark_atlas_color_failure(keyp);
7018            return None;
7019        }
7020        if !self.alloc_or_schedule_color(w, h) {
7021            defer_atlas_glyph(&mut self.atlas_color_deferred, keyp);
7022            return None;
7023        }
7024        let x = self.atlas_color.next_x;
7025        let y = self.atlas_color.next_y;
7026        self.atlas_color.next_x += w + 1;
7027        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
7028
7029        let layout = wgpu::TexelCopyBufferLayout {
7030            offset: 0,
7031            bytes_per_row: Some(w * 4),
7032            rows_per_image: Some(h),
7033        };
7034        let size = wgpu::Extent3d {
7035            width: w,
7036            height: h,
7037            depth_or_array_layers: 1,
7038        };
7039        self.queue.write_texture(
7040            wgpu::TexelCopyTextureInfoBase {
7041                texture: &self.atlas_color.tex,
7042                mip_level: 0,
7043                origin: wgpu::Origin3d { x, y, z: 0 },
7044                aspect: wgpu::TextureAspect::All,
7045            },
7046            &gb.data,
7047            layout,
7048            size,
7049        );
7050        let info = GlyphInfo {
7051            u0: x as f32 / self.atlas_color.size as f32,
7052            v0: y as f32 / self.atlas_color.size as f32,
7053            u1: (x + w) as f32 / self.atlas_color.size as f32,
7054            v1: (y + h) as f32 / self.atlas_color.size as f32,
7055            w: w as f32,
7056            h: h as f32,
7057            last_touch: touch,
7058            last_used_frame: self.frame_index,
7059        };
7060        self.atlas_color.used_area = self
7061            .atlas_color
7062            .used_area
7063            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7064        self.atlas_color.map.insert(keyp, info);
7065        Some(info)
7066    }
7067
7068    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
7069        let Some(x_end) = self
7070            .atlas_mask
7071            .next_x
7072            .checked_add(w)
7073            .and_then(|v| v.checked_add(1))
7074        else {
7075            return false;
7076        };
7077        if x_end >= self.atlas_mask.size {
7078            self.atlas_mask.next_x = 1;
7079            let Some(next_y) = self
7080                .atlas_mask
7081                .next_y
7082                .checked_add(self.atlas_mask.row_h)
7083                .and_then(|v| v.checked_add(1))
7084            else {
7085                return false;
7086            };
7087            self.atlas_mask.next_y = next_y;
7088            self.atlas_mask.row_h = 0;
7089        }
7090        let Some(y_end) = self
7091            .atlas_mask
7092            .next_y
7093            .checked_add(h)
7094            .and_then(|v| v.checked_add(1))
7095        else {
7096            return false;
7097        };
7098        y_end < self.atlas_mask.size
7099    }
7100
7101    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
7102        let Some(x_end) = self
7103            .atlas_color
7104            .next_x
7105            .checked_add(w)
7106            .and_then(|v| v.checked_add(1))
7107        else {
7108            return false;
7109        };
7110        if x_end >= self.atlas_color.size {
7111            self.atlas_color.next_x = 1;
7112            let Some(next_y) = self
7113                .atlas_color
7114                .next_y
7115                .checked_add(self.atlas_color.row_h)
7116                .and_then(|v| v.checked_add(1))
7117            else {
7118                return false;
7119            };
7120            self.atlas_color.next_y = next_y;
7121            self.atlas_color.row_h = 0;
7122        }
7123        let Some(y_end) = self
7124            .atlas_color
7125            .next_y
7126            .checked_add(h)
7127            .and_then(|v| v.checked_add(1))
7128        else {
7129            return false;
7130        };
7131        y_end < self.atlas_color.size
7132    }
7133
7134    /// Place a `w` x `h` cell in the mask atlas, or schedule the work needed to
7135    /// make room. Growth and eviction both re-pack the atlas, which would move
7136    /// glyphs already batched into the frame in flight, so they are deferred to
7137    /// the next `begin_frame`; the caller treats a `false` as "skip this glyph".
7138    fn alloc_or_schedule_mask(&mut self, w: u32, h: u32) -> bool {
7139        if self.atlas_mask.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7140            || self
7141                .atlas_mask
7142                .used_area
7143                .saturating_add(u64::from(w) * u64::from(h))
7144                >= self.atlas_mask.size as u64 * self.atlas_mask.size as u64
7145        {
7146            self.atlas_mask_pending.compact = true;
7147            return false;
7148        }
7149        if self.alloc_space_mask(w, h) {
7150            return true;
7151        }
7152        if self.atlas_mask.size < MAX_GLYPH_ATLAS_SIZE {
7153            self.atlas_mask_pending.grow = true;
7154        } else {
7155            self.atlas_mask_pending.compact = true;
7156        }
7157        false
7158    }
7159
7160    /// See [`Self::alloc_or_schedule_mask`].
7161    fn alloc_or_schedule_color(&mut self, w: u32, h: u32) -> bool {
7162        if self.atlas_color.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7163            || self
7164                .atlas_color
7165                .used_area
7166                .saturating_add(u64::from(w) * u64::from(h))
7167                >= self.atlas_color.size as u64 * self.atlas_color.size as u64
7168        {
7169            self.atlas_color_pending.compact = true;
7170            return false;
7171        }
7172        if self.alloc_space_color(w, h) {
7173            return true;
7174        }
7175        if self.atlas_color.size < MAX_GLYPH_ATLAS_SIZE {
7176            self.atlas_color_pending.grow = true;
7177        } else {
7178            self.atlas_color_pending.compact = true;
7179        }
7180        false
7181    }
7182}
7183
7184/// Pack the shape brush enums into the single `bits` word the shaders read, so
7185/// they cost one flat varying instead of three locations.
7186fn pack_shape_bits(brush_type: u32, grad_kind: u32, tile_mode: u32, cap: u32) -> u32 {
7187    debug_assert!(brush_type < 4 && grad_kind < 4 && tile_mode < 4 && cap < 4);
7188    (brush_type & 3) | ((grad_kind & 3) << 2) | ((tile_mode & 3) << 4) | ((cap & 3) << 6)
7189}
7190
7191/// Packed brush fields shared by the shape instances (border, ellipse,
7192/// ellipse border, arc). Gradient endpoints are shape-local px; the shaders
7193/// recenter `(0,0)` at the shape top-left. Radial packs `center` into
7194/// `grad_p0` and `radius` into `grad_p1.x`; sweep packs `center` into
7195/// `grad_p0`.
7196///
7197/// `rect` is the shape's scene-space bounds and `transform` the accumulated
7198/// scene transform.
7199/// Endpoints are converted from shape-local px through the inverse linear
7200/// part so rotation and uniform scale cancel against the shader's
7201/// un-rotation. Non-uniform scale and shear distort the gradient the same
7202/// way they distort the shape (the shader un-rotates but cannot un-scale
7203/// pixels).
7204fn brush_to_shape_fields(
7205    brush: &Brush,
7206    rect: &repose_core::Rect,
7207    transform: &Transform,
7208) -> (u32, u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2], u32) {
7209    let to_local = |p: Vec2| {
7210        let m = transform.linear();
7211        let det = m[0] * m[3] - m[1] * m[2];
7212        if det.abs() < 1e-12 {
7213            return [p.x, p.y];
7214        }
7215        [
7216            (m[3] * p.x - m[1] * p.y) / det,
7217            (-m[2] * p.x + m[0] * p.y) / det,
7218        ]
7219    };
7220    match brush {
7221        Brush::Solid(c) => (
7222            0u32,
7223            0u32,
7224            c.to_linear(),
7225            [0.0; 4],
7226            [0.0; 2],
7227            [0.0; 2],
7228            0u32,
7229        ),
7230        Brush::Linear {
7231            start,
7232            end,
7233            start_color,
7234            end_color,
7235        } => (
7236            1u32,
7237            0u32,
7238            start_color.to_linear(),
7239            end_color.to_linear(),
7240            to_local(*start),
7241            to_local(*end),
7242            0u32,
7243        ),
7244        Brush::LinearNormalized {
7245            start,
7246            end,
7247            start_color,
7248            end_color,
7249        } => (
7250            1u32,
7251            0u32,
7252            start_color.to_linear(),
7253            end_color.to_linear(),
7254            to_local(Vec2 {
7255                x: start.x * rect.w,
7256                y: start.y * rect.h,
7257            }),
7258            to_local(Vec2 {
7259                x: end.x * rect.w,
7260                y: end.y * rect.h,
7261            }),
7262            0u32,
7263        ),
7264        Brush::Radial {
7265            center,
7266            radius,
7267            start_color,
7268            end_color,
7269        } => (
7270            1u32,
7271            1u32,
7272            start_color.to_linear(),
7273            end_color.to_linear(),
7274            to_local(*center),
7275            [radius.max(0.0), 0.0],
7276            0u32,
7277        ),
7278        Brush::Sweep {
7279            center,
7280            start_color,
7281            end_color,
7282        } => (
7283            1u32,
7284            2u32,
7285            start_color.to_linear(),
7286            end_color.to_linear(),
7287            to_local(*center),
7288            [0.0, 0.0],
7289            0u32,
7290        ),
7291        _ => (0u32, 0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2], 0u32),
7292    }
7293}
7294
7295fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
7296    match brush {
7297        Brush::Solid(c) => (
7298            0u32,
7299            c.to_linear(),
7300            [0.0, 0.0, 0.0, 0.0],
7301            [0.0, 0.0],
7302            [0.0, 1.0],
7303        ),
7304        Brush::Linear {
7305            start,
7306            end,
7307            start_color,
7308            end_color,
7309        } => (
7310            1u32,
7311            start_color.to_linear(),
7312            end_color.to_linear(),
7313            [start.x, start.y],
7314            [end.x, end.y],
7315        ),
7316        Brush::LinearNormalized {
7317            start,
7318            end,
7319            start_color,
7320            end_color,
7321        } => (
7322            1u32,
7323            start_color.to_linear(),
7324            end_color.to_linear(),
7325            [start.x, start.y],
7326            [end.x, end.y],
7327        ),
7328        Brush::Radial { start_color, .. } => (
7329            0u32,
7330            start_color.to_linear(),
7331            [0.0, 0.0, 0.0, 0.0],
7332            [0.0, 0.0],
7333            [0.0, 1.0],
7334        ),
7335        Brush::Sweep { start_color, .. } => (
7336            0u32,
7337            start_color.to_linear(),
7338            [0.0, 0.0, 0.0, 0.0],
7339            [0.0, 0.0],
7340            [0.0, 1.0],
7341        ),
7342        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
7343    }
7344}
7345
7346/// Fallback color when a [`Brush`] reaches a solid-only path (glyph atlas
7347/// uploads for gradient text). Uses the gradient's start color.
7348#[allow(dead_code)]
7349fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
7350    match brush {
7351        Brush::Solid(c) => c.to_linear(),
7352        Brush::Linear { start_color, .. } => start_color.to_linear(),
7353        Brush::LinearNormalized { start_color, .. } => start_color.to_linear(),
7354        Brush::Radial { start_color, .. } => start_color.to_linear(),
7355        Brush::Sweep { start_color, .. } => start_color.to_linear(),
7356        _ => [0.0; 4],
7357    }
7358}
7359
7360fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
7361    let size = 1024u32;
7362    let tex = device.create_texture(&wgpu::TextureDescriptor {
7363        label: Some("glyph atlas A8"),
7364        size: wgpu::Extent3d {
7365            width: size,
7366            height: size,
7367            depth_or_array_layers: 1,
7368        },
7369        mip_level_count: 1,
7370        sample_count: 1,
7371        dimension: wgpu::TextureDimension::D2,
7372        format: wgpu::TextureFormat::R8Unorm,
7373        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7374        view_formats: &[],
7375    });
7376    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7377    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7378        label: Some("glyph atlas sampler A8"),
7379        address_mode_u: wgpu::AddressMode::ClampToEdge,
7380        address_mode_v: wgpu::AddressMode::ClampToEdge,
7381        address_mode_w: wgpu::AddressMode::ClampToEdge,
7382        mag_filter: wgpu::FilterMode::Linear,
7383        min_filter: wgpu::FilterMode::Linear,
7384        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7385        ..Default::default()
7386    });
7387
7388    AtlasA8 {
7389        tex,
7390        view,
7391        sampler,
7392        size,
7393        next_x: 1,
7394        next_y: 1,
7395        row_h: 0,
7396        map: HashMap::new(),
7397        used_area: 0,
7398    }
7399}
7400
7401fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
7402    let size = 1024u32;
7403    let tex = device.create_texture(&wgpu::TextureDescriptor {
7404        label: Some("glyph atlas RGBA"),
7405        size: wgpu::Extent3d {
7406            width: size,
7407            height: size,
7408            depth_or_array_layers: 1,
7409        },
7410        mip_level_count: 1,
7411        sample_count: 1,
7412        dimension: wgpu::TextureDimension::D2,
7413        format: wgpu::TextureFormat::Rgba8UnormSrgb,
7414        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7415        view_formats: &[],
7416    });
7417    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7418    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7419        label: Some("glyph atlas sampler RGBA"),
7420        address_mode_u: wgpu::AddressMode::ClampToEdge,
7421        address_mode_v: wgpu::AddressMode::ClampToEdge,
7422        address_mode_w: wgpu::AddressMode::ClampToEdge,
7423        mag_filter: wgpu::FilterMode::Linear,
7424        min_filter: wgpu::FilterMode::Linear,
7425        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7426        ..Default::default()
7427    });
7428    AtlasRGBA {
7429        tex,
7430        view,
7431        sampler,
7432        size,
7433        next_x: 1,
7434        next_y: 1,
7435        row_h: 0,
7436        map: HashMap::new(),
7437        used_area: 0,
7438    }
7439}
7440
7441#[cfg(feature = "winit-surface")]
7442impl WgpuSurfaceBackend {
7443    /// Drop the surface, keeping device/queue/pipelines. Releases the old
7444    /// native-window binding. Call `recreate_surface` to present again.
7445    pub fn take_surface(&mut self) -> Option<wgpu::Surface<'static>> {
7446        self.surface.take()
7447    }
7448
7449    /// Create a fresh surface for `window` on the retained instance and
7450    /// configure it. Recovers from `CurrentSurfaceTexture::Lost`, where
7451    /// reconfiguring the old surface object cannot help.
7452    pub fn recreate_surface(&mut self, window: &Arc<winit::window::Window>) -> anyhow::Result<()> {
7453        self.window = Some(window.clone());
7454        let size = window.inner_size();
7455        if size.width == 0 || size.height == 0 {
7456            self.surface = None;
7457            self.pending_reconfigure = false;
7458            self.renderer.resize(0, 0);
7459            anyhow::bail!("window has zero size; surface recreation deferred");
7460        }
7461        let Some(instance) = self.instance.as_ref() else {
7462            anyhow::bail!("no wgpu instance retained; cannot recreate surface")
7463        };
7464        let Some(config) = self.surface_config.as_mut() else {
7465            anyhow::bail!("no surface config retained; cannot recreate surface")
7466        };
7467        let (width, height) = clamp_output_size(
7468            size.width,
7469            size.height,
7470            self.renderer.device.limits().max_texture_dimension_2d,
7471        );
7472        config.width = width;
7473        config.height = height;
7474        let surface = instance.create_surface(window.clone())?;
7475        surface.configure(&self.renderer.device, config);
7476        self.surface = Some(surface);
7477        self.pending_reconfigure = false;
7478        self.renderer.resize(width, height);
7479        Ok(())
7480    }
7481}
7482
7483#[cfg(feature = "winit-surface")]
7484impl RenderBackend for WgpuSurfaceBackend {
7485    fn configure_surface(&mut self, width: u32, height: u32) -> (u32, u32) {
7486        if width == 0 || height == 0 {
7487            self.renderer.resize(0, 0);
7488            self.pending_reconfigure = true;
7489            return (0, 0);
7490        }
7491        let (width, height) = clamp_output_size(
7492            width,
7493            height,
7494            self.renderer.device.limits().max_texture_dimension_2d,
7495        );
7496        if self.renderer.output_width == width && self.renderer.output_height == height {
7497            if let Some(ref mut config) = self.surface_config {
7498                config.width = width;
7499                config.height = height;
7500            }
7501            return (width, height);
7502        }
7503        if let Some(ref mut config) = self.surface_config {
7504            config.width = width;
7505            config.height = height;
7506        }
7507        self.renderer.resize(width, height);
7508        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref())
7509        {
7510            surface.configure(&self.renderer.device, config);
7511        }
7512        (width, height)
7513    }
7514
7515    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) -> bool {
7516        if self.pending_reconfigure {
7517            if let (Some(surface), Some(config)) =
7518                (self.surface.as_ref(), self.surface_config.as_ref())
7519            {
7520                surface.configure(&self.renderer.device, config);
7521            }
7522            self.pending_reconfigure = false;
7523        }
7524
7525        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
7526            request_frame();
7527            return false;
7528        }
7529        if let Err(error) = self.renderer.begin_frame() {
7530            log::warn!("begin renderer frame: {error:#}");
7531            request_frame();
7532            return false;
7533        }
7534
7535        let Some(surface) = self.surface.as_ref() else {
7536            self.renderer.end_frame();
7537            request_frame();
7538            return false;
7539        };
7540        let frame = match surface.get_current_texture() {
7541            wgpu::CurrentSurfaceTexture::Success(f) => f,
7542            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
7543                self.pending_reconfigure = true;
7544                f
7545            }
7546            other @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost) => {
7547                self.surface = None;
7548                let result = self
7549                    .window
7550                    .clone()
7551                    .ok_or_else(|| anyhow::anyhow!("no window retained for surface recreation"))
7552                    .and_then(|window| self.recreate_surface(&window));
7553                if let Err(error) = result {
7554                    log::warn!("surface {other:?}; recreation deferred: {error:#}");
7555                }
7556                self.renderer.end_frame();
7557                request_frame();
7558                return false;
7559            }
7560            other => {
7561                match other {
7562                    wgpu::CurrentSurfaceTexture::Validation => {
7563                        log::warn!("surface {other:?}; reconfiguring next frame");
7564                        self.pending_reconfigure = true;
7565                    }
7566                    wgpu::CurrentSurfaceTexture::Timeout
7567                    | wgpu::CurrentSurfaceTexture::Occluded => {
7568                        log::debug!("surface {other:?}; retrying next frame");
7569                    }
7570                    _ => {}
7571                }
7572                self.renderer.end_frame();
7573                request_frame();
7574                return false;
7575            }
7576        };
7577
7578        // The swapchain extent and the renderer's size are updated from
7579        // separate events, so they can disagree for a frame mid-resize (most
7580        // visibly when dragging a window into a portrait shape). With MSAA on,
7581        // a color attachment whose extent differs from its resolve target
7582        // fails wgpu validation, which drops the whole pass *including its
7583        // clear*, so the just-reconfigured swapchain keeps undefined contents
7584        // and presents as green/black streaks. Adopt the swapchain's size and
7585        // retry next frame instead of rendering an invalid pass.
7586        let (swap_w, swap_h) = (frame.texture.width(), frame.texture.height());
7587        if swap_w != self.renderer.output_width || swap_h != self.renderer.output_height {
7588            log::debug!(
7589                "swapchain {swap_w}x{swap_h} != renderer {}x{}; resyncing",
7590                self.renderer.output_width,
7591                self.renderer.output_height
7592            );
7593            drop(frame);
7594            if let Some(config) = self.surface_config.as_mut() {
7595                config.width = swap_w;
7596                config.height = swap_h;
7597            }
7598            self.renderer.resize(swap_w, swap_h);
7599            self.pending_reconfigure = true;
7600            self.renderer.end_frame();
7601            request_frame();
7602            return false;
7603        }
7604
7605        let swap_view = if let Some(view_format) = self
7606            .surface_config
7607            .as_ref()
7608            .and_then(|c| c.view_formats.iter().find(|f| f.is_srgb()).copied())
7609        {
7610            frame.texture.create_view(&wgpu::TextureViewDescriptor {
7611                format: Some(view_format),
7612                ..Default::default()
7613            })
7614        } else {
7615            frame
7616                .texture
7617                .create_view(&wgpu::TextureViewDescriptor::default())
7618        };
7619        let mut encoder =
7620            self.renderer
7621                .device
7622                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7623                    label: Some("frame encoder"),
7624                });
7625
7626        self.renderer.render_scene_to_encoder_with_texture(
7627            scene,
7628            &mut encoder,
7629            &swap_view,
7630            Some(&frame.texture),
7631            None,
7632        );
7633
7634        //NOTE: The WebGL HAL present path (fullscreen triangle / blit) does not
7635        // restore gl.colorMask. Hence this is needed to prevent frames from going transparent.
7636        {
7637            let _reset = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
7638                label: Some("webgl color_mask reset before present"),
7639                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7640                    view: &swap_view,
7641                    resolve_target: None,
7642                    ops: wgpu::Operations {
7643                        load: wgpu::LoadOp::Load,
7644                        store: wgpu::StoreOp::Store,
7645                    },
7646                    depth_slice: None,
7647                })],
7648                depth_stencil_attachment: None,
7649                timestamp_writes: None,
7650                occlusion_query_set: None,
7651                multiview_mask: None,
7652            });
7653        }
7654
7655        self.renderer
7656            .queue
7657            .submit(std::iter::once(encoder.finish()));
7658        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
7659            log::warn!("queue.present panicked: {:?}", e);
7660            self.renderer.end_frame();
7661            request_frame();
7662            return false;
7663        }
7664        self.renderer.end_frame();
7665        true
7666    }
7667}
7668
7669impl WgpuSceneRenderer {
7670    /// Open a translator-owned flatten layer for a perspective `PushTransform`.
7671    ///
7672    /// True perspective cannot ride the affine instance fast path, so the
7673    /// subtree renders flat into an offscreen layer and is composited back
7674    /// projectively on the matching pop (CSS-style flattening). The layer
7675    /// rect is the currently visible scissor in this target: content outside
7676    /// it is invisible in the parent, so clipping it in the layer changes
7677    /// nothing. Children keep the node's affine part on the stack (so
7678    /// `combine` stays affine-only) plus a layer-local shift, exactly like
7679    /// producer-owned blur layers - which is exact under rigid ancestors
7680    /// (translations commute) and the documented layer contract otherwise.
7681    #[allow(clippy::too_many_arguments)]
7682    fn replay_active_clips(
7683        &mut self,
7684        clips: &[ActiveClip],
7685        parent_transform: &Transform,
7686        origin: (f32, f32),
7687        target_size: (f32, f32),
7688        pass: &mut Pass,
7689        encoder: &mut wgpu::CommandEncoder,
7690    ) -> Vec<ActiveClip> {
7691        let mut current = repose_core::Rect {
7692            x: 0.0,
7693            y: 0.0,
7694            w: target_size.0,
7695            h: target_size.1,
7696        };
7697        let mut replayed = Vec::with_capacity(clips.len());
7698        for (index, clip) in clips.iter().enumerate() {
7699            let has_inverse = clips[..index].iter().any(ActiveClip::difference);
7700            let mut blocked = clip.blocked();
7701            if has_inverse && !blocked {
7702                log::error!(
7703                    "unsupported clip nesting: a Difference clip cannot contain another clip"
7704                );
7705                blocked = true;
7706            }
7707            let difference = clip.difference();
7708            let mut command = None;
7709            match clip {
7710                ActiveClip::Rect {
7711                    off: _,
7712                    cnt: _,
7713                    rect,
7714                    radii,
7715                    ..
7716                } => {
7717                    let local_rect = parent_rect_to_layer_rect(*rect, parent_transform, origin)
7718                        .unwrap_or(repose_core::Rect {
7719                            x: 0.0,
7720                            y: 0.0,
7721                            w: 0.0,
7722                            h: 0.0,
7723                        });
7724                    let local_radii =
7725                        parent_radii_to_layer_radii(*radii, parent_transform).unwrap_or([0.0; 4]);
7726                    let next = if difference {
7727                        current
7728                    } else {
7729                        intersect(current, local_rect)
7730                    };
7731                    let scissor = rect_to_scissor(
7732                        next,
7733                        target_size.0.max(1.0) as u32,
7734                        target_size.1.max(1.0) as u32,
7735                    );
7736                    let has_area = local_rect.x.is_finite()
7737                        && local_rect.y.is_finite()
7738                        && local_rect.w.is_finite()
7739                        && local_rect.h.is_finite()
7740                        && local_rect.w > 0.0
7741                        && local_rect.h > 0.0;
7742                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7743                        blocked = blocked || !difference;
7744                    } else {
7745                        let ndc = rect_to_ndc(local_rect, target_size.0, target_size.1);
7746                        let instance = ClipInstance {
7747                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
7748                            radii: local_radii,
7749                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
7750                        };
7751                        let bytes = bytemuck::bytes_of(&instance);
7752                        if self
7753                            .clip_ring
7754                            .grow_to_fit(
7755                                &self.device,
7756                                encoder,
7757                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
7758                            )
7759                            .is_ok()
7760                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
7761                        {
7762                            command = Some(Cmd::ClipPush {
7763                                off: new_off,
7764                                cnt: 1,
7765                                scissor,
7766                                difference,
7767                                applied: true,
7768                            });
7769                            replayed.push(ActiveClip::Rect {
7770                                off: new_off,
7771                                cnt: 1,
7772                                rect: local_rect,
7773                                radii: local_radii,
7774                                scissor,
7775                                difference,
7776                                applied: true,
7777                                blocked: false,
7778                            });
7779                        } else {
7780                            blocked = blocked || !difference;
7781                        }
7782                    }
7783                    current = next;
7784                }
7785                ActiveClip::Vector {
7786                    mesh,
7787                    source,
7788                    affine,
7789                    ..
7790                } => {
7791                    let local_affine =
7792                        parent_affine_to_layer_affine(*affine, parent_transform, origin)
7793                            .unwrap_or([0.0; 6]);
7794                    let aabb = mesh_aabb(source, local_affine);
7795                    let next = if difference {
7796                        current
7797                    } else {
7798                        intersect(current, aabb)
7799                    };
7800                    let scissor = rect_to_scissor(
7801                        next,
7802                        target_size.0.max(1.0) as u32,
7803                        target_size.1.max(1.0) as u32,
7804                    );
7805                    let has_area = aabb.x.is_finite()
7806                        && aabb.y.is_finite()
7807                        && aabb.w.is_finite()
7808                        && aabb.h.is_finite()
7809                        && aabb.w > 0.0
7810                        && aabb.h > 0.0
7811                        && mesh
7812                            .as_ref()
7813                            .is_some_and(|mesh| mesh.vertex_count > 0 && mesh.index_count > 0);
7814                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7815                        blocked = blocked || !difference;
7816                    } else if let Some(mesh_gpu) = mesh.clone()
7817                        && let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
7818                            local_affine,
7819                            &repose_core::PaintDesc::Solid,
7820                        ))
7821                    {
7822                        command = Some(Cmd::VectorClipPush {
7823                            mesh: mesh_gpu.clone(),
7824                            uoff,
7825                            scissor,
7826                            difference,
7827                            applied: true,
7828                        });
7829                        replayed.push(ActiveClip::Vector {
7830                            mesh: Some(mesh_gpu),
7831                            source: source.clone(),
7832                            uoff,
7833                            affine: local_affine,
7834                            scissor,
7835                            difference,
7836                            applied: true,
7837                            blocked: false,
7838                        });
7839                    } else {
7840                        blocked = blocked || !difference;
7841                    }
7842                    current = next;
7843                }
7844            }
7845            if blocked {
7846                current = repose_core::Rect {
7847                    x: 0.0,
7848                    y: 0.0,
7849                    w: 0.0,
7850                    h: 0.0,
7851                };
7852            }
7853            if command.is_none() {
7854                replayed.push(match clip {
7855                    ActiveClip::Rect {
7856                        rect,
7857                        radii,
7858                        scissor,
7859                        difference,
7860                        ..
7861                    } => ActiveClip::Rect {
7862                        off: 0,
7863                        cnt: 0,
7864                        rect: *rect,
7865                        radii: *radii,
7866                        scissor: *scissor,
7867                        difference: *difference,
7868                        applied: false,
7869                        blocked,
7870                    },
7871                    ActiveClip::Vector {
7872                        mesh,
7873                        source,
7874                        affine,
7875                        scissor,
7876                        difference,
7877                        ..
7878                    } => ActiveClip::Vector {
7879                        mesh: mesh.clone(),
7880                        source: source.clone(),
7881                        uoff: 0,
7882                        affine: parent_affine_to_layer_affine(*affine, parent_transform, origin)
7883                            .unwrap_or([0.0; 6]),
7884                        scissor: *scissor,
7885                        difference: *difference,
7886                        applied: false,
7887                        blocked,
7888                    },
7889                });
7890            }
7891            if let Some(command) = command {
7892                pass.cmds.push(command);
7893            }
7894            pass.active_scissor = if blocked {
7895                None
7896            } else {
7897                let scissor = rect_to_scissor(
7898                    current,
7899                    target_size.0.max(1.0) as u32,
7900                    target_size.1.max(1.0) as u32,
7901                );
7902                (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
7903            };
7904        }
7905        replayed
7906    }
7907
7908    #[allow(clippy::too_many_arguments)]
7909    fn push_perspective_layer(
7910        &mut self,
7911        node: Transform,
7912        top: Transform,
7913        transform_stack: &mut Vec<Transform>,
7914        scissor_stack: &mut Vec<repose_core::Rect>,
7915        root_clip_rect: &mut repose_core::Rect,
7916        current_target_size: &mut (f32, f32),
7917        current_pass: &mut Pass,
7918        active_clips: &mut Vec<ActiveClip>,
7919        passes: &mut Vec<Pass>,
7920        target_stack: &mut Vec<PassTarget>,
7921        flatten_stack: &mut Vec<FlattenRecord>,
7922        id_head: &mut u32,
7923        pass_id_head: &mut u64,
7924        ids_used: &mut Vec<u32>,
7925        encoder: &mut wgpu::CommandEncoder,
7926    ) {
7927        // Full projective map: affine ancestors over the node's map.
7928        // Ancestors are affine by construction (perspective always flattens
7929        // at push, and only stripped affines reach the stack).
7930        let map =
7931            Transform::compose_projective(&top.projective_matrix(), &node.projective_matrix());
7932        let scr = scissor_stack.last().copied().unwrap_or(*root_clip_rect);
7933        let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
7934        if !scr.w.is_finite()
7935            || !scr.h.is_finite()
7936            || scr.w < 1.0
7937            || scr.h < 1.0
7938            || scr.w > max_dimension
7939            || scr.h > max_dimension
7940        {
7941            return;
7942        }
7943        let w = scr.w.ceil();
7944        let h = scr.h.ceil();
7945        let layer_rect = repose_core::Rect {
7946            x: scr.x,
7947            y: scr.y,
7948            w,
7949            h,
7950        };
7951        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
7952            return;
7953        }
7954        let layer_id = *id_head;
7955        *id_head = id_head.wrapping_add(1);
7956        ids_used.push(layer_id);
7957
7958        let stack_len = transform_stack.len();
7959        // Children render with the ancestors' map only: the node's own
7960        // affine part lives in `map` and applies once, at composite time.
7961        // (Pushing the stripped affine here too would foreshorten twice.)
7962        transform_stack.push(top);
7963        transform_stack.push(Transform::translate(-layer_rect.x, -layer_rect.y));
7964
7965        let saved_scissor_stack = std::mem::replace(
7966            scissor_stack,
7967            vec![repose_core::Rect {
7968                x: 0.0,
7969                y: 0.0,
7970                w,
7971                h,
7972            }],
7973        );
7974        let saved_root = std::mem::replace(
7975            root_clip_rect,
7976            repose_core::Rect {
7977                x: 0.0,
7978                y: 0.0,
7979                w,
7980                h,
7981            },
7982        );
7983        let saved_size = std::mem::replace(current_target_size, (w, h));
7984        let prev_target = current_pass.target;
7985        let saved_active_scissor = current_pass.active_scissor;
7986        let saved_clips = std::mem::take(active_clips);
7987        let layer_pass_id = *pass_id_head;
7988        *pass_id_head += 1;
7989        let mut layer_pass = Pass {
7990            id: layer_pass_id,
7991            target: PassTarget::Layer(layer_id),
7992            initial_scissor: (0, 0, w as u32, h as u32),
7993            active_scissor: Some((0, 0, w as u32, h as u32)),
7994            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
7995            cmds: Vec::new(),
7996        };
7997        let layer_clips = self.replay_active_clips(
7998            &saved_clips,
7999            &top,
8000            (layer_rect.x, layer_rect.y),
8001            (w, h),
8002            &mut layer_pass,
8003            encoder,
8004        );
8005        *active_clips = layer_clips;
8006        let saved = std::mem::replace(current_pass, layer_pass);
8007        passes.push(saved);
8008        target_stack.push(prev_target);
8009        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8010        *current_target_size = (w, h);
8011        flatten_stack.push(FlattenRecord {
8012            stack_len,
8013            layer_id,
8014            map,
8015            layer_rect,
8016            saved_scissor_stack,
8017            saved_root,
8018            saved_size,
8019            saved_active_scissor,
8020            saved_clips,
8021        });
8022    }
8023
8024    /// Close a flatten layer: restore the parent target and composite the
8025    /// layer texture through the recorded projective map.
8026    #[allow(clippy::too_many_arguments)]
8027    fn pop_perspective_layer(
8028        &mut self,
8029        rec: FlattenRecord,
8030        scissor_stack: &mut Vec<repose_core::Rect>,
8031        root_clip_rect: &mut repose_core::Rect,
8032        current_target_size: &mut (f32, f32),
8033        current_pass: &mut Pass,
8034        active_clips: &mut Vec<ActiveClip>,
8035        passes: &mut Vec<Pass>,
8036        target_stack: &mut Vec<PassTarget>,
8037        pass_id_head: &mut u64,
8038        encoder: &mut wgpu::CommandEncoder,
8039    ) {
8040        *scissor_stack = rec.saved_scissor_stack;
8041        *root_clip_rect = rec.saved_root;
8042        *current_target_size = rec.saved_size;
8043        *active_clips = rec.saved_clips;
8044        let resumed_pass_id = *pass_id_head;
8045        *pass_id_head += 1;
8046        let saved = std::mem::replace(
8047            current_pass,
8048            Pass {
8049                id: resumed_pass_id,
8050                target: target_stack.pop().unwrap_or(PassTarget::Surface),
8051                initial_scissor: rec.saved_active_scissor.unwrap_or((0, 0, 1, 1)),
8052                active_scissor: rec.saved_active_scissor,
8053                clear_color: None,
8054                cmds: Vec::new(),
8055            },
8056        );
8057        passes.push(saved);
8058
8059        // Project the layer-rect corners (parent space) to NDC in the
8060        // resumed (parent) target, keeping each corner's homogeneous w for
8061        // perspective-correct sampling.
8062        let (tw, th) = rec.saved_size;
8063        let r = rec.layer_rect;
8064        let corners = [
8065            (r.x, r.y),
8066            (r.x + r.w, r.y),
8067            (r.x + r.w, r.y + r.h),
8068            (r.x, r.y + r.h),
8069        ];
8070        let mut ndc = [[0.0f32; 2]; 4];
8071        let mut ws = [1.0f32; 4];
8072        let mut all_behind = true;
8073        for (i, (x, y)) in corners.iter().enumerate() {
8074            let w_raw = rec.map[6] * x + rec.map[7] * y + rec.map[8];
8075            let w = if w_raw.abs() < 1e-6 {
8076                if w_raw < 0.0 { -1e-6 } else { 1e-6 }
8077            } else {
8078                w_raw
8079            };
8080            if w > 0.0 {
8081                all_behind = false;
8082            }
8083            let px = (rec.map[0] * x + rec.map[1] * y + rec.map[2]) / w;
8084            let py = (rec.map[3] * x + rec.map[4] * y + rec.map[5]) / w;
8085            ndc[i] = [px / tw * 2.0 - 1.0, 1.0 - py / th * 2.0];
8086            ws[i] = w;
8087        }
8088        if all_behind {
8089            // Entire subtree behind the viewer: nothing to composite (the
8090            // layer pass still ran, but its output is correctly discarded).
8091            return;
8092        }
8093        let Some(layer) = self.layer_pool.get(&rec.layer_id) else {
8094            return;
8095        };
8096        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
8097        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
8098        let inst = ProjectiveInstance {
8099            c0: ndc[0],
8100            c1: ndc[1],
8101            c2: ndc[2],
8102            c3: ndc[3],
8103            uv: [0.0, 0.0, uv_u1, uv_v1],
8104            w: ws,
8105            alpha: 1.0,
8106            _pad: [0.0; 3],
8107        };
8108        if self
8109            .projective_ring
8110            .grow_to_fit(
8111                &self.device,
8112                encoder,
8113                std::mem::size_of::<ProjectiveInstance>() as u64,
8114            )
8115            .is_err()
8116        {
8117            return;
8118        }
8119        let bytes = bytemuck::bytes_of(&inst);
8120        let Ok(off) = self.projective_ring.alloc_write(&self.queue, bytes) else {
8121            return;
8122        };
8123        current_pass.cmds.push(Cmd::CompositeProjective {
8124            off,
8125            cnt: 1,
8126            layer_id: rec.layer_id,
8127        });
8128    }
8129
8130    fn mesh_cache_key(mesh: &Arc<repose_core::VectorMeshData>) -> MeshCacheKey {
8131        MeshCacheKey {
8132            source: Arc::as_ptr(mesh) as usize,
8133            index: 0,
8134            kind: 1,
8135            vertex_len: mesh.vertices.len(),
8136            index_len: mesh.indices.len(),
8137        }
8138    }
8139
8140    fn overlay_mesh_cache_key(
8141        meshes: &Arc<[repose_core::VectorMeshData]>,
8142        index: usize,
8143    ) -> MeshCacheKey {
8144        let mesh = &meshes[index];
8145        MeshCacheKey {
8146            source: meshes.as_ptr() as usize,
8147            index,
8148            kind: 2,
8149            vertex_len: mesh.vertices.len(),
8150            index_len: mesh.indices.len(),
8151        }
8152    }
8153
8154    fn mesh_owner_matches(owner: &MeshOwner, other: &MeshOwner) -> bool {
8155        match (owner, other) {
8156            (MeshOwner::Object(a), MeshOwner::Object(b)) => Arc::ptr_eq(a, b),
8157            (MeshOwner::Overlay(a), MeshOwner::Overlay(b)) => Arc::ptr_eq(a, b),
8158            _ => false,
8159        }
8160    }
8161
8162    fn evict_mesh_cache(&mut self) -> bool {
8163        let victim = self
8164            .mesh_cache
8165            .iter()
8166            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
8167            .min_by_key(|(_, entry)| entry.last_touch_frame)
8168            .map(|(key, _)| *key);
8169        let Some(victim) = victim else {
8170            return false;
8171        };
8172        let Some(entry) = self.mesh_cache.remove(&victim) else {
8173            return false;
8174        };
8175        self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8176        true
8177    }
8178
8179    fn reserve_mesh_cache(&mut self, bytes: u64) -> bool {
8180        while self.mesh_cache.len() >= MAX_MESH_CACHE_ENTRIES
8181            || self.mesh_cache_bytes_total.saturating_add(bytes) > MAX_MESH_CACHE_BYTES
8182        {
8183            if !self.evict_mesh_cache() {
8184                return false;
8185            }
8186        }
8187        true
8188    }
8189
8190    fn reserve_mesh_gpu_budget(&mut self, bytes: u64) -> bool {
8191        while self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes {
8192            if !self.evict_mesh_cache() {
8193                return false;
8194            }
8195        }
8196        true
8197    }
8198
8199    fn upload_mesh_geometry_with_owner(
8200        &mut self,
8201        mesh: &repose_core::VectorMeshData,
8202        key: MeshCacheKey,
8203        owner: MeshOwner,
8204    ) -> Option<MeshGpu> {
8205        let touch = self.next_cache_touch();
8206        let cached = self
8207            .mesh_cache
8208            .get(&key)
8209            .filter(|entry| Self::mesh_owner_matches(&entry.owner, &owner))
8210            .map(|entry| entry.gpu.clone());
8211        if let Some(gpu) = cached {
8212            if let Some(entry) = self.mesh_cache.get_mut(&key) {
8213                entry.last_used_frame = self.frame_index;
8214                entry.last_touch_frame = touch;
8215            }
8216            return Some(gpu);
8217        }
8218        if let Some(entry) = self.mesh_cache.remove(&key) {
8219            self.mesh_cache_bytes_total =
8220                self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8221        }
8222        if mesh.vertices.is_empty() || mesh.indices.is_empty() {
8223            log::warn!("vector mesh has no geometry");
8224            return None;
8225        }
8226        if !mesh.indices.len().is_multiple_of(3) {
8227            log::warn!("vector mesh index count is not a triangle list");
8228            return None;
8229        }
8230        let vertex_count = u32::try_from(mesh.vertices.len()).ok()?;
8231        let index_count = u32::try_from(mesh.indices.len()).ok()?;
8232        if mesh.indices.iter().any(|&index| index >= vertex_count) {
8233            log::warn!("vector mesh index is outside its vertex buffer");
8234            return None;
8235        }
8236        if mesh.vertices.iter().any(|vertex| {
8237            vertex.pos.iter().any(|value| !value.is_finite())
8238                || vertex.color.iter().any(|value| !value.is_finite())
8239                || vertex.uv.iter().any(|value| !value.is_finite())
8240        }) {
8241            log::warn!("vector mesh contains non-finite vertex data");
8242            return None;
8243        }
8244        let verts: Vec<MeshVertex> = mesh
8245            .vertices
8246            .iter()
8247            .map(|v| MeshVertex {
8248                pos: v.pos,
8249                color: v.color,
8250                uv: v.uv,
8251            })
8252            .collect();
8253        let vbytes = bytemuck::cast_slice(&verts);
8254        let ibytes = bytemuck::cast_slice(&mesh.indices[..]);
8255        let bytes = u64::try_from(vbytes.len())
8256            .ok()?
8257            .checked_add(u64::try_from(ibytes.len()).ok()?)?;
8258        let max_buffer_size = self.device.limits().max_buffer_size;
8259        if vbytes.len() as u64 > max_buffer_size || ibytes.len() as u64 > max_buffer_size {
8260            return None;
8261        }
8262        let cacheable = bytes <= MAX_MESH_CACHE_BYTES
8263            && self.reserve_mesh_cache(bytes)
8264            && self.reserve_mesh_gpu_budget(bytes);
8265        let vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
8266            label: Some("vector mesh vertices"),
8267            size: vbytes.len().max(4) as u64,
8268            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
8269            mapped_at_creation: false,
8270        });
8271        let indices = self.device.create_buffer(&wgpu::BufferDescriptor {
8272            label: Some("vector mesh indices"),
8273            size: ibytes.len().max(4) as u64,
8274            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
8275            mapped_at_creation: false,
8276        });
8277        self.queue.write_buffer(&vertices, 0, vbytes);
8278        self.queue.write_buffer(&indices, 0, ibytes);
8279        let gpu = MeshGpu {
8280            vertices,
8281            indices,
8282            vertex_count,
8283            index_count,
8284            bytes,
8285        };
8286        if cacheable {
8287            let touch = self.next_cache_touch();
8288            self.mesh_cache.insert(
8289                key,
8290                MeshCacheEntry {
8291                    owner,
8292                    gpu: gpu.clone(),
8293                    last_used_frame: self.frame_index,
8294                    last_touch_frame: touch,
8295                },
8296            );
8297            self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_add(bytes);
8298        }
8299        Some(gpu)
8300    }
8301
8302    fn upload_mesh_geometry(&mut self, mesh: &Arc<repose_core::VectorMeshData>) -> Option<MeshGpu> {
8303        self.upload_mesh_geometry_with_owner(
8304            mesh,
8305            Self::mesh_cache_key(mesh),
8306            MeshOwner::Object(mesh.clone()),
8307        )
8308    }
8309
8310    fn upload_overlay_mesh(
8311        &mut self,
8312        meshes: &Arc<[repose_core::VectorMeshData]>,
8313        index: usize,
8314    ) -> Option<MeshGpu> {
8315        self.upload_mesh_geometry_with_owner(
8316            &meshes[index],
8317            Self::overlay_mesh_cache_key(meshes, index),
8318            MeshOwner::Overlay(meshes.clone()),
8319        )
8320    }
8321
8322    fn alloc_mesh_uniform(&mut self, u: MeshUniform) -> Option<u64> {
8323        let size = std::mem::size_of::<MeshUniform>() as u64;
8324        let next = self
8325            .mesh_uniform_head
8326            .checked_add(self.mesh_uniform_alignment)?;
8327        if next > self.mesh_uniform_cap || next > u64::from(u32::MAX) {
8328            log::warn!("mesh uniform buffer exhausted; remaining meshes skipped");
8329            return None;
8330        }
8331        let slot = self.mesh_uniform_head;
8332        if slot.checked_add(size)? > self.mesh_uniform_cap {
8333            return None;
8334        }
8335        self.queue
8336            .write_buffer(&self.mesh_uniform_buf, slot, bytemuck::bytes_of(&u));
8337        self.mesh_uniform_head = next;
8338        Some(slot)
8339    }
8340
8341    /// Shared isolation + backdrop composite for a source that needs the
8342    /// backdrop: snapshot the parent's region, render the source alone into a
8343    /// translator-owned graphics layer via `emit_source`, then composite that
8344    /// layer over the snapshot with the backdrop-blend shader. Works for
8345    /// surface parents and layer parents alike: the snapshot copy, isolation
8346    /// layer, and composite quad are all expressed in the parent target's
8347    /// pixel space. Callers must invoke this while the current pass targets
8348    /// the recorded parent: the translator never splits passes between here
8349    /// and the appended composite (only `BeginLayer`/perspective push new
8350    /// passes, and neither can intervene mid-call). The executor re-checks
8351    /// this (`BlendLayer.parent`) and skips a misplaced composite rather than
8352    /// drawing over the wrong target.
8353    ///
8354    /// `bounds` is the source's AABB in parent-target pixels; `emit_source`
8355    /// receives the isolation pass, the source-shifted transform, and the
8356    /// layer size in pixels.
8357    #[allow(clippy::too_many_arguments)]
8358    fn emit_isolated_blend_source(
8359        &mut self,
8360        bounds: repose_core::Rect,
8361        blend: repose_core::BlendMode,
8362        current_transform: &repose_core::Transform,
8363        current_pass: &mut Pass,
8364        active_clips: &[ActiveClip],
8365        passes: &mut Vec<Pass>,
8366        id_head: &mut u32,
8367        pass_id_head: &mut u64,
8368        ids_used: &mut Vec<u32>,
8369        scissor: (u32, u32, u32, u32),
8370        encoder: &mut wgpu::CommandEncoder,
8371        fb_w: f32,
8372        fb_h: f32,
8373        emit_source: impl FnOnce(
8374            &mut Self,
8375            &mut Pass,
8376            &repose_core::Transform,
8377            f32,
8378            f32,
8379            &mut wgpu::CommandEncoder,
8380        ),
8381    ) {
8382        if scissor.2 == 0 || scissor.3 == 0 || active_clips.iter().any(ActiveClip::blocked) {
8383            return;
8384        }
8385        let parent_target = current_pass.target;
8386        let (parent_w, parent_h) = match parent_target {
8387            PassTarget::Surface => (fb_w, fb_h),
8388            PassTarget::Layer(id) => match self.layer_pool.get(&id) {
8389                Some(layer) => (layer.width as f32, layer.height as f32),
8390                None => return,
8391            },
8392        };
8393        let local_rect = intersect(
8394            bounds,
8395            repose_core::Rect {
8396                x: 0.0,
8397                y: 0.0,
8398                w: parent_w,
8399                h: parent_h,
8400            },
8401        );
8402        if !local_rect.x.is_finite()
8403            || !local_rect.y.is_finite()
8404            || !local_rect.w.is_finite()
8405            || !local_rect.h.is_finite()
8406            || local_rect.w <= 0.0
8407            || local_rect.h <= 0.0
8408            || local_rect.w > self.device.limits().max_texture_dimension_2d as f32
8409            || local_rect.h > self.device.limits().max_texture_dimension_2d as f32
8410        {
8411            return;
8412        }
8413        let layer_rect = repose_core::Rect {
8414            x: local_rect.x.floor(),
8415            y: local_rect.y.floor(),
8416            w: (local_rect.x + local_rect.w).ceil() - local_rect.x.floor(),
8417            h: (local_rect.y + local_rect.h).ceil() - local_rect.y.floor(),
8418        };
8419        let w = layer_rect.w;
8420        let h = layer_rect.h;
8421        let local_rect = layer_rect;
8422        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8423            return;
8424        }
8425        let layer_id = *id_head;
8426        *id_head = id_head.wrapping_add(1);
8427        ids_used.push(layer_id);
8428
8429        // Snapshot the parent target region before compositing: the blend
8430        // shader samples it as the backdrop. The copy runs at execution
8431        // time, after all earlier passes have rendered.
8432        let snapshot_format = match parent_target {
8433            PassTarget::Surface if self.working_space => wgpu::TextureFormat::Rgba16Float,
8434            PassTarget::Surface => self.output_format,
8435            PassTarget::Layer(_) => self.layer_target_format(),
8436        };
8437        if !self.alloc_blend_snapshot(layer_id, w as u32, h as u32, snapshot_format) {
8438            log::warn!("blend snapshot budget exhausted; blend skipped");
8439            return;
8440        }
8441        let source_pass_id = *pass_id_head;
8442        *pass_id_head += 1;
8443        let resumed_pass_id = *pass_id_head;
8444        *pass_id_head += 1;
8445        self.blend_copies.push(BlendCopy {
8446            pass_id: resumed_pass_id,
8447            blend_id: layer_id,
8448            target: parent_target,
8449            region: layer_rect,
8450        });
8451
8452        // Render the mesh alone into the layer (layer-local shift so the
8453        // layer owns exactly the mesh bbox), then resume the parent pass.
8454        // The composite runs in the resumed parent pass.
8455        let mut layer_pass = Pass {
8456            id: source_pass_id,
8457            target: PassTarget::Layer(layer_id),
8458            initial_scissor: (0, 0, w as u32, h as u32),
8459            active_scissor: Some((0, 0, w as u32, h as u32)),
8460            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8461            cmds: Vec::new(),
8462        };
8463        self.replay_active_clips(
8464            active_clips,
8465            current_transform,
8466            (local_rect.x, local_rect.y),
8467            (w, h),
8468            &mut layer_pass,
8469            encoder,
8470        );
8471        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8472        let shift = repose_core::Transform::translate(-local_rect.x, -local_rect.y);
8473        let local = current_transform.combine(&shift);
8474        emit_source(self, &mut layer_pass, &local, w, h, encoder);
8475        let saved = std::mem::replace(
8476            current_pass,
8477            Pass {
8478                id: resumed_pass_id,
8479                target: parent_target,
8480                initial_scissor: scissor,
8481                active_scissor: Some(scissor),
8482                clear_color: None,
8483                cmds: Vec::new(),
8484            },
8485        );
8486        passes.push(layer_pass);
8487        passes.push(saved);
8488
8489        // Composite quad over the mesh bbox in the parent target's pixel
8490        // space. The executor maps this NDC against `parent`, so layer
8491        // parents at any origin work.
8492        let ndc = {
8493            let cx = local_rect.x + local_rect.w * 0.5;
8494            let cy = local_rect.y + local_rect.h * 0.5;
8495            let ndc_cx = (cx / parent_w) * 2.0 - 1.0;
8496            let ndc_cy = 1.0 - (cy / parent_h) * 2.0;
8497            let ndc_w = (local_rect.w / parent_w) * 2.0;
8498            let ndc_h = (local_rect.h / parent_h) * 2.0;
8499            [ndc_cx, ndc_cy, ndc_w, ndc_h]
8500        };
8501        let inst = BlendInstance {
8502            xywh: ndc,
8503            uv: [0.0, 0.0, 1.0, 1.0],
8504            color: [1.0, 1.0, 1.0, 1.0],
8505            fwd_mat: [1.0, 0.0, 0.0, 1.0],
8506            mode: blend.shader_mode(),
8507            _pad: [0.0; 3],
8508        };
8509        if self
8510            .blend_ring
8511            .grow_to_fit(
8512                &self.device,
8513                encoder,
8514                std::mem::size_of::<BlendInstance>() as u64,
8515            )
8516            .is_err()
8517        {
8518            return;
8519        }
8520        let bytes = bytemuck::bytes_of(&inst);
8521        let Ok(off) = self.blend_ring.alloc_write(&self.queue, bytes) else {
8522            return;
8523        };
8524        current_pass.cmds.push(Cmd::BlendLayer {
8525            off,
8526            cnt: 1,
8527            src_layer: layer_id,
8528            dst_layer: Some(layer_id),
8529            parent: parent_target,
8530            scissor,
8531        });
8532    }
8533
8534    /// [`Self::emit_isolated_blend_source`] for a `VectorMesh`: the isolation
8535    /// layer owns exactly the mesh bbox, shifted so the mesh lands at its
8536    /// world position inside the layer.
8537    #[allow(clippy::too_many_arguments)]
8538    fn emit_isolated_blend(
8539        &mut self,
8540        mesh: std::sync::Arc<repose_core::VectorMeshData>,
8541        transform: [f32; 6],
8542        paint: repose_core::PaintDesc,
8543        blend: repose_core::BlendMode,
8544        current_transform: &repose_core::Transform,
8545        current_pass: &mut Pass,
8546        active_clips: &[ActiveClip],
8547        passes: &mut Vec<Pass>,
8548        id_head: &mut u32,
8549        pass_id_head: &mut u64,
8550        ids_used: &mut Vec<u32>,
8551        scissor: (u32, u32, u32, u32),
8552        encoder: &mut wgpu::CommandEncoder,
8553        fb_w: f32,
8554        fb_h: f32,
8555    ) {
8556        let affine = combine_mesh_affine(current_transform, transform);
8557        let bounds = mesh_aabb(&mesh, affine);
8558        self.emit_isolated_blend_source(
8559            bounds,
8560            blend,
8561            current_transform,
8562            current_pass,
8563            active_clips,
8564            passes,
8565            id_head,
8566            pass_id_head,
8567            ids_used,
8568            scissor,
8569            encoder,
8570            fb_w,
8571            fb_h,
8572            |slf, pass, local, _w, _h, _encoder| {
8573                slf.emit_vector_mesh(
8574                    local,
8575                    &mesh,
8576                    transform,
8577                    &paint,
8578                    repose_core::BlendMode::Alpha,
8579                    &mut pass.cmds,
8580                );
8581            },
8582        );
8583    }
8584
8585    /// [`Self::emit_isolated_blend_source`] for an `ImageBlended` quad. The
8586    /// isolation layer is sized to the fitted draw rect and the image is
8587    /// sampled with its own UVs, so `fit`/`filter`/`source_rect` survive the
8588    /// round trip; the source layer composites with `Alpha` and the blend
8589    /// itself happens in the backdrop pass.
8590    #[allow(clippy::too_many_arguments)]
8591    fn emit_isolated_image(
8592        &mut self,
8593        draw_rect: repose_core::Rect,
8594        handle: u64,
8595        uv_rect: [f32; 4],
8596        tint: repose_core::Color,
8597        tile: bool,
8598        filter: ImageFilter,
8599        blend: repose_core::BlendMode,
8600        current_transform: &repose_core::Transform,
8601        current_pass: &mut Pass,
8602        active_clips: &[ActiveClip],
8603        passes: &mut Vec<Pass>,
8604        id_head: &mut u32,
8605        pass_id_head: &mut u64,
8606        ids_used: &mut Vec<u32>,
8607        scissor: (u32, u32, u32, u32),
8608        encoder: &mut wgpu::CommandEncoder,
8609        fb_w: f32,
8610        fb_h: f32,
8611    ) {
8612        let bounds = affine_aabb(current_transform, &draw_rect);
8613        self.emit_isolated_blend_source(
8614            bounds,
8615            blend,
8616            current_transform,
8617            current_pass,
8618            active_clips,
8619            passes,
8620            id_head,
8621            pass_id_head,
8622            ids_used,
8623            scissor,
8624            encoder,
8625            fb_w,
8626            fb_h,
8627            |slf, pass, local, w, h, encoder| {
8628                let (ndc_center, fwd_mat) = rect_to_instance_ndc(draw_rect, local, w, h);
8629                let inst = GlyphInstance {
8630                    xywh: ndc_center,
8631                    uv: uv_rect,
8632                    color: tint.to_linear(),
8633                    fwd_mat,
8634                };
8635                if let Some((off, _)) =
8636                    slf.glyph_color
8637                        .upload(&slf.device, &slf.queue, encoder, &[inst])
8638                {
8639                    pass.cmds.push(Cmd::ImageRgba {
8640                        off,
8641                        cnt: 1,
8642                        handle,
8643                        filter,
8644                        tile,
8645                        blend: repose_core::BlendMode::Alpha,
8646                    });
8647                }
8648            },
8649        );
8650    }
8651
8652    fn recycle_blend_snapshot(&mut self, snapshot: BlendSnapshot) {
8653        let key = snapshot.key;
8654        if let Some(old) = self.blend_snapshot_pool.insert(key, snapshot) {
8655            self.blend_snapshot_bytes_total =
8656                self.blend_snapshot_bytes_total.saturating_sub(old.bytes);
8657        }
8658        while self.blend_snapshot_bytes_total > MAX_BLEND_SNAPSHOT_BYTES
8659            || self.blend_snapshot_pool.len() > MAX_BLEND_SNAPSHOT_POOL_ENTRIES
8660        {
8661            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8662                break;
8663            };
8664            if let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) {
8665                self.blend_snapshot_bytes_total = self
8666                    .blend_snapshot_bytes_total
8667                    .saturating_sub(snapshot.bytes);
8668            }
8669        }
8670    }
8671
8672    fn recycle_blend_snapshots(&mut self) {
8673        for (_, snapshot) in std::mem::take(&mut self.blend_snapshots) {
8674            self.recycle_blend_snapshot(snapshot);
8675        }
8676    }
8677
8678    fn alloc_blend_snapshot(
8679        &mut self,
8680        layer_id: u32,
8681        w: u32,
8682        h: u32,
8683        format: wgpu::TextureFormat,
8684    ) -> bool {
8685        let key = BlendSnapshotKey {
8686            format,
8687            width: w,
8688            height: h,
8689        };
8690        if self
8691            .blend_snapshots
8692            .get(&layer_id)
8693            .is_some_and(|snapshot| snapshot.key == key)
8694        {
8695            return true;
8696        }
8697        if w == 0 || h == 0 {
8698            return false;
8699        }
8700        if let Some(old) = self.blend_snapshots.remove(&layer_id) {
8701            self.recycle_blend_snapshot(old);
8702        }
8703        if let Some(snapshot) = self.blend_snapshot_pool.remove(&key) {
8704            self.blend_snapshots.insert(layer_id, snapshot);
8705            return true;
8706        }
8707        let Ok(bytes) = texture_storage_bytes(format, w, h) else {
8708            return false;
8709        };
8710        if bytes > MAX_BLEND_SNAPSHOT_BYTES {
8711            return false;
8712        }
8713        while self.blend_snapshot_bytes_total.saturating_add(bytes) > MAX_BLEND_SNAPSHOT_BYTES
8714            || self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes
8715        {
8716            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8717                return false;
8718            };
8719            let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) else {
8720                return false;
8721            };
8722            self.blend_snapshot_bytes_total = self
8723                .blend_snapshot_bytes_total
8724                .saturating_sub(snapshot.bytes);
8725        }
8726        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
8727            label: Some("blend backdrop snapshot"),
8728            size: wgpu::Extent3d {
8729                width: w,
8730                height: h,
8731                depth_or_array_layers: 1,
8732            },
8733            mip_level_count: 1,
8734            sample_count: 1,
8735            dimension: wgpu::TextureDimension::D2,
8736            format,
8737            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
8738            view_formats: &[],
8739        });
8740        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
8741        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8742            label: Some("blend snapshot bind"),
8743            layout: &self.image_bind_layout_rgba,
8744            entries: &[
8745                wgpu::BindGroupEntry {
8746                    binding: 0,
8747                    resource: wgpu::BindingResource::TextureView(&view),
8748                },
8749                wgpu::BindGroupEntry {
8750                    binding: 1,
8751                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
8752                },
8753            ],
8754        });
8755        self.blend_snapshot_bytes_total = self.blend_snapshot_bytes_total.saturating_add(bytes);
8756        self.blend_snapshots.insert(
8757            layer_id,
8758            BlendSnapshot {
8759                key,
8760                texture: tex,
8761                bind,
8762                bytes,
8763            },
8764        );
8765        true
8766    }
8767
8768    fn remove_blend_snapshot(&mut self, layer_id: u32) {
8769        if let Some(snapshot) = self.blend_snapshots.remove(&layer_id) {
8770            self.recycle_blend_snapshot(snapshot);
8771        }
8772    }
8773
8774    fn blend_snapshot_texture(&self, layer_id: u32) -> Option<wgpu::Texture> {
8775        self.blend_snapshots
8776            .get(&layer_id)
8777            .map(|s| s.texture.clone())
8778    }
8779
8780    #[allow(clippy::too_many_arguments)]
8781    fn emit_vector_mesh(
8782        &mut self,
8783        current_transform: &Transform,
8784        mesh: &Arc<repose_core::VectorMeshData>,
8785        transform: [f32; 6],
8786        paint: &repose_core::PaintDesc,
8787        blend: repose_core::BlendMode,
8788        cmds: &mut Vec<Cmd>,
8789    ) {
8790        let affine = combine_mesh_affine(current_transform, transform);
8791        let Some(mesh_gpu) = self.upload_mesh_geometry(mesh) else {
8792            return;
8793        };
8794        let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(affine, paint)) else {
8795            return;
8796        };
8797        cmds.push(Cmd::VectorMesh {
8798            mesh: mesh_gpu,
8799            uoff,
8800            blend,
8801        });
8802    }
8803
8804    fn touch_callback_scope(&mut self, scope: CallbackScopeKey) {
8805        self.callback_scope_clock = self.callback_scope_clock.wrapping_add(1);
8806        let tick = self.callback_scope_clock;
8807        self.callback_scope_uses.insert(
8808            scope,
8809            CallbackScopeUse {
8810                tick,
8811                frame: self.frame_index,
8812            },
8813        );
8814    }
8815
8816    fn remove_callback_scope(&mut self, scope: &CallbackScopeKey) {
8817        self.callback_scoped_resources.remove(scope);
8818        self.callback_scope_uses.remove(scope);
8819        self.callback_scope_payloads.remove(scope);
8820    }
8821
8822    fn evict_callback_scope(&mut self, protected: &HashSet<CallbackScopeKey>) -> bool {
8823        let candidate = self
8824            .callback_scoped_resources
8825            .keys()
8826            .filter(|scope| !protected.contains(*scope))
8827            .min_by_key(|scope| {
8828                self.callback_scope_uses
8829                    .get(scope)
8830                    .map(|usage| usage.tick)
8831                    .unwrap_or(0)
8832            })
8833            .cloned();
8834        let Some(scope) = candidate else {
8835            return false;
8836        };
8837        self.remove_callback_scope(&scope);
8838        true
8839    }
8840
8841    fn prune_callback_scopes(&mut self) {
8842        let current_frame = self.frame_index;
8843        let mut dead: Vec<(CallbackScopeKey, u64)> = self
8844            .callback_scope_uses
8845            .iter()
8846            .filter_map(|(scope, usage)| {
8847                let payload_dead = self
8848                    .callback_scope_payloads
8849                    .get(scope)
8850                    .is_none_or(|weak| weak.upgrade().is_none());
8851                (payload_dead && !scope.callback.reusable).then(|| (scope.clone(), usage.tick))
8852            })
8853            .collect();
8854        dead.sort_by_key(|(_, tick)| *tick);
8855        for (scope, _) in dead {
8856            self.remove_callback_scope(&scope);
8857        }
8858
8859        if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8860            return;
8861        }
8862        let mut inactive: Vec<(CallbackScopeKey, u64)> = self
8863            .callback_scope_uses
8864            .iter()
8865            .filter(|(_, usage)| usage.frame != current_frame)
8866            .map(|(scope, usage)| (scope.clone(), usage.tick))
8867            .collect();
8868        inactive.sort_by_key(|(_, tick)| *tick);
8869        for (scope, _) in inactive {
8870            if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
8871                break;
8872            }
8873            self.remove_callback_scope(&scope);
8874        }
8875    }
8876
8877    pub fn begin_frame(&mut self) -> anyhow::Result<()> {
8878        if self.frame_active {
8879            self.last_render_error = Some("renderer frame is already active".into());
8880            anyhow::bail!("renderer frame is already active");
8881        }
8882        self.last_render_error = None;
8883        self.frame_active = true;
8884        self.frame_index = self.frame_index.wrapping_add(1);
8885        self.apply_atlas_pending();
8886        self.slug_cache.next_frame();
8887        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8888            composite.begin_frame();
8889        }
8890        for resources in self.callback_scoped_resources.values_mut() {
8891            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8892                composite.begin_frame();
8893            }
8894        }
8895        Ok(())
8896    }
8897
8898    pub fn end_frame(&mut self) {
8899        if !self.frame_active {
8900            return;
8901        }
8902        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
8903            composite.end_frame();
8904        }
8905        for resources in self.callback_scoped_resources.values_mut() {
8906            if let Some(composite) = resources.get_mut::<DepthComposite>() {
8907                composite.end_frame();
8908            }
8909        }
8910        self.prune_callback_scopes();
8911        self.evict_unused_images();
8912        self.frame_active = false;
8913    }
8914
8915    pub fn last_render_error(&self) -> Option<&str> {
8916        self.last_render_error.as_deref()
8917    }
8918
8919    pub fn render_scene_to_encoder(
8920        &mut self,
8921        scene: &Scene,
8922        encoder: &mut wgpu::CommandEncoder,
8923        target_view: &wgpu::TextureView,
8924        clear_color_override: Option<[f64; 4]>,
8925    ) {
8926        self.render_scene_to_encoder_with_texture(
8927            scene,
8928            encoder,
8929            target_view,
8930            None,
8931            clear_color_override,
8932        )
8933    }
8934
8935    /// Same as [`render_scene_to_encoder`](Self::render_scene_to_encoder),
8936    /// plus the render target texture for isolated-blend backdrop snapshots.
8937    /// Pass `None` when the texture is unavailable (swapchain views); then
8938    /// surface-targeted isolated blends are skipped (the isolated source
8939    /// layer composites nowhere, so the mesh disappears).
8940    pub fn render_scene_to_encoder_with_texture(
8941        &mut self,
8942        scene: &Scene,
8943        encoder: &mut wgpu::CommandEncoder,
8944        target_view: &wgpu::TextureView,
8945        target_texture: Option<&wgpu::Texture>,
8946        clear_color_override: Option<[f64; 4]>,
8947    ) {
8948        if self.output_width == 0 || self.output_height == 0 {
8949            return;
8950        }
8951        self.sync_text_caches();
8952        fn visible(aabb: repose_core::Rect, clip: repose_core::Rect) -> bool {
8953            if !aabb.x.is_finite()
8954                || !aabb.y.is_finite()
8955                || !aabb.w.is_finite()
8956                || !aabb.h.is_finite()
8957                || aabb.w <= 0.0
8958                || aabb.h <= 0.0
8959            {
8960                return false;
8961            }
8962            let aabb = repose_core::Rect {
8963                x: aabb.x - 1.0,
8964                y: aabb.y - 1.0,
8965                w: aabb.w + 2.0,
8966                h: aabb.h + 2.0,
8967            };
8968            let clipped = intersect(aabb, clip);
8969            clipped.x.is_finite()
8970                && clipped.y.is_finite()
8971                && clipped.w.is_finite()
8972                && clipped.h.is_finite()
8973                && clipped.w > 0.0
8974                && clipped.h > 0.0
8975        }
8976
8977        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
8978            let x0 = (x / fb_w) * 2.0 - 1.0;
8979            let y0 = 1.0 - (y / fb_h) * 2.0;
8980            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
8981            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
8982            let min_x = x0.min(x1);
8983            let min_y = y0.min(y1);
8984            let w_ndc = (x1 - x0).abs();
8985            let h_ndc = (y1 - y0).abs();
8986            [min_x, min_y, w_ndc, h_ndc]
8987        }
8988
8989        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
8990            if fb_w == 0
8991                || fb_h == 0
8992                || !r.x.is_finite()
8993                || !r.y.is_finite()
8994                || !r.w.is_finite()
8995                || !r.h.is_finite()
8996                || r.w <= 0.0
8997                || r.h <= 0.0
8998            {
8999                return (0, 0, 0, 0);
9000            }
9001            // Inward: a clip must not admit any pixel outside the rect it
9002            // came from. Rounding out let clipped content bleed a pixel past
9003            // every clip edge, which shows up as a 1px line hugging the edge
9004            // of a letterboxed or clipped view.
9005            let x0 = r.x.ceil().max(0.0).min(fb_w as f32);
9006            let y0 = r.y.ceil().max(0.0).min(fb_h as f32);
9007            let x1 = (r.x + r.w).floor().max(x0).min(fb_w as f32);
9008            let y1 = (r.y + r.h).floor().max(y0).min(fb_h as f32);
9009            if x1 <= x0 || y1 <= y0 {
9010                return (0, 0, 0, 0);
9011            }
9012            (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
9013        }
9014
9015        let fb_w = self.output_width as f32;
9016        let fb_h = self.output_height as f32;
9017
9018        let mut passes: Vec<Pass> = Vec::with_capacity(1);
9019        let clear_color = clear_color_override
9020            .map(|color| {
9021                let alpha = color[3];
9022                [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha]
9023            })
9024            .unwrap_or_else(|| {
9025                let color = scene.clear_color.to_linear();
9026                let alpha = color[3] as f64;
9027                [
9028                    color[0] as f64 * alpha,
9029                    color[1] as f64 * alpha,
9030                    color[2] as f64 * alpha,
9031                    alpha,
9032                ]
9033            });
9034        let mut next_pass_id = 0u64;
9035        let mut current_pass: Pass = Pass {
9036            id: next_pass_id,
9037            target: PassTarget::Surface,
9038            initial_scissor: (0, 0, self.output_width, self.output_height),
9039            active_scissor: Some((0, 0, self.output_width, self.output_height)),
9040            clear_color: Some([
9041                clear_color[0] as f32,
9042                clear_color[1] as f32,
9043                clear_color[2] as f32,
9044                clear_color[3] as f32,
9045            ]),
9046            cmds: Vec::with_capacity(scene.nodes.len()),
9047        };
9048        next_pass_id += 1;
9049        let mut target_stack: Vec<PassTarget> = Vec::new();
9050        let mut layer_stack: Vec<LayerState> = Vec::new();
9051        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
9052
9053        struct Batch {
9054            rects: Vec<RectInstance>,
9055            borders: Vec<BorderInstance>,
9056            ellipses: Vec<EllipseInstance>,
9057            e_borders: Vec<EllipseBorderInstance>,
9058            arcs: Vec<ArcInstance>,
9059            masks: Vec<GlyphInstance>,
9060            colors: Vec<GlyphInstance>,
9061        }
9062
9063        impl Batch {
9064            fn new() -> Self {
9065                Self {
9066                    rects: vec![],
9067                    borders: vec![],
9068                    ellipses: vec![],
9069                    e_borders: vec![],
9070                    arcs: vec![],
9071                    masks: vec![],
9072                    colors: vec![],
9073                }
9074            }
9075
9076            fn is_empty(&self) -> bool {
9077                self.rects.is_empty()
9078                    && self.borders.is_empty()
9079                    && self.ellipses.is_empty()
9080                    && self.e_borders.is_empty()
9081                    && self.arcs.is_empty()
9082                    && self.masks.is_empty()
9083                    && self.colors.is_empty()
9084            }
9085
9086            fn flush(
9087                &mut self,
9088                pipes: (
9089                    &mut InstancedPipe<RectInstance>,
9090                    &mut InstancedPipe<BorderInstance>,
9091                    &mut InstancedPipe<EllipseInstance>,
9092                    &mut InstancedPipe<EllipseBorderInstance>,
9093                    &mut InstancedPipe<ArcInstance>,
9094                ),
9095                glyph_pipes: (
9096                    &mut InstancedPipe<GlyphInstance>,
9097                    &mut InstancedPipe<GlyphInstance>,
9098                ),
9099                device: &wgpu::Device,
9100                queue: &wgpu::Queue,
9101                encoder: &mut wgpu::CommandEncoder,
9102                cmds: &mut Vec<Cmd>,
9103            ) {
9104                let (rects, borders, ellipses, e_borders, arcs) = pipes;
9105                let (masks, colors) = glyph_pipes;
9106
9107                macro_rules! flush_one {
9108                    ($buf:ident, $pipe:expr, $variant:ident) => {
9109                        if !self.$buf.is_empty() {
9110                            if let Some((off, cnt)) =
9111                                $pipe.upload(device, queue, encoder, &self.$buf)
9112                            {
9113                                cmds.push(Cmd::$variant { off, cnt });
9114                            }
9115                            self.$buf.clear();
9116                        }
9117                    };
9118                }
9119
9120                flush_one!(rects, rects, Rect);
9121                flush_one!(borders, borders, Border);
9122                flush_one!(ellipses, ellipses, Ellipse);
9123                flush_one!(e_borders, e_borders, EllipseBorder);
9124                flush_one!(arcs, arcs, Arc);
9125                flush_one!(masks, masks, GlyphsMask);
9126                flush_one!(colors, colors, GlyphsColor);
9127            }
9128        }
9129
9130        self.rects.reset();
9131        self.borders.reset();
9132        self.ellipses.reset();
9133        self.ellipse_borders.reset();
9134        self.arcs.reset();
9135        self.glyph_mask.reset();
9136        self.glyph_color.reset();
9137        self.clip_ring.reset();
9138        self.blur_ring.reset();
9139        self.nv12.reset();
9140
9141        self.slug_ring.reset();
9142        self.mesh_uniform_head = 0;
9143        self.projective_ring.reset();
9144        self.blend_ring.reset();
9145        for id in self.flatten_layer_ids.drain(..) {
9146            if let Some(layer) = self.layer_pool.remove(&id) {
9147                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
9148                if layer.transient {
9149                    self.transient_layer_bytes_total =
9150                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
9151                }
9152            }
9153        }
9154        self.producer_layer_ids.clear();
9155        self.recycle_blend_snapshots();
9156        self.blend_copies.clear();
9157        let mut batch = Batch::new();
9158        // The blend mode is part of the run key: instances only batch together when
9159        // they resolve to the same pipeline.
9160        let mut image_run: Option<(
9161            u64,
9162            ImageFilter,
9163            bool,
9164            repose_core::BlendMode,
9165            Vec<GlyphInstance>,
9166        )> = None;
9167        let mut nv12_run: Option<(u64, ImageFilter, repose_core::BlendMode, Vec<Nv12Instance>)> =
9168            None;
9169        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
9170        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
9171        let mut flatten_stack: Vec<FlattenRecord> = Vec::new();
9172        let mut flatten_id_head: u32 = FLATTEN_ID_BASE;
9173        let mut flatten_ids_used: Vec<u32> = Vec::new();
9174        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
9175        let mut active_clips: Vec<ActiveClip> = Vec::with_capacity(8);
9176        let mut root_clip_rect = repose_core::Rect {
9177            x: 0.0,
9178            y: 0.0,
9179            w: fb_w,
9180            h: fb_h,
9181        };
9182
9183        let mut current_prim: Option<&'static str> = None;
9184
9185        macro_rules! flush_if_prim_changed {
9186            ($prim:literal, $pipe:expr) => {
9187                if current_prim != Some($prim) {
9188                    flush_batch!();
9189                    current_prim = Some($prim);
9190                }
9191            };
9192        }
9193
9194        macro_rules! flush_image_runs {
9195            () => {{
9196                if let Some((handle, filter, tile, blend, instances)) = image_run.take()
9197                    && let Some((off, cnt)) =
9198                        self.glyph_color
9199                            .upload(&self.device, &self.queue, encoder, &instances)
9200                {
9201                    current_pass.cmds.push(Cmd::ImageRgba {
9202                        off,
9203                        cnt,
9204                        handle,
9205                        filter,
9206                        tile,
9207                        blend,
9208                    });
9209                }
9210                if let Some((handle, filter, blend, instances)) = nv12_run.take()
9211                    && let Some((off, cnt)) =
9212                        self.nv12
9213                            .upload(&self.device, &self.queue, encoder, &instances)
9214                {
9215                    current_pass.cmds.push(Cmd::ImageNv12 {
9216                        off,
9217                        cnt,
9218                        handle,
9219                        filter,
9220                        blend,
9221                    });
9222                }
9223            }};
9224        }
9225
9226        macro_rules! flush_primitives {
9227            () => {{
9228                if !batch.is_empty() {
9229                    batch.flush(
9230                        (
9231                            &mut self.rects,
9232                            &mut self.borders,
9233                            &mut self.ellipses,
9234                            &mut self.ellipse_borders,
9235                            &mut self.arcs,
9236                        ),
9237                        (&mut self.glyph_mask, &mut self.glyph_color),
9238                        &self.device,
9239                        &self.queue,
9240                        encoder,
9241                        &mut current_pass.cmds,
9242                    )
9243                }
9244            }};
9245        }
9246
9247        macro_rules! flush_batch {
9248            () => {{
9249                flush_image_runs!();
9250                flush_primitives!();
9251            }};
9252        }
9253        let mut pending_layer_composite: Option<Cmd> = None;
9254        let shadow_layer_ids: HashSet<u32> = scene
9255            .nodes
9256            .iter()
9257            .filter_map(|node| match node {
9258                SceneNode::CompositeShadow { layer_id, .. } => Some(*layer_id),
9259                _ => None,
9260            })
9261            .collect();
9262        macro_rules! flush_pending_layer {
9263            () => {
9264                if let Some(command) = pending_layer_composite.take() {
9265                    current_pass.cmds.push(command);
9266                }
9267            };
9268        }
9269        for node in &scene.nodes {
9270            let t_identity = Transform::identity();
9271            let current_transform = transform_stack.last().unwrap_or(&t_identity);
9272
9273            // `Image` and `ImageBlended` differ only in the blend mode, and
9274            // or-patterns cannot bind a field one variant lacks, so lift both
9275            // out of the match and share the whole image path below.
9276            let image_spec = match node {
9277                SceneNode::Image {
9278                    rect,
9279                    handle,
9280                    tint,
9281                    style,
9282                } => Some((*rect, *handle, *tint, *style, repose_core::BlendMode::Alpha)),
9283                SceneNode::ImageBlended {
9284                    rect,
9285                    handle,
9286                    tint,
9287                    style,
9288                    blend,
9289                } => Some((*rect, *handle, *tint, *style, *blend)),
9290                _ => None,
9291            };
9292            if let Some((rect, handle, tint, style, blend)) = image_spec {
9293                let ImagePaintStyle {
9294                    fit,
9295                    filter,
9296                    source_rect,
9297                    alignment,
9298                } = style;
9299                let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9300                if !visible(affine_aabb(current_transform, &rect), clip) {
9301                    continue;
9302                }
9303                flush_batch!();
9304                let (img_w, img_h, is_nv12) = match self.resolve_image_for_draw(handle) {
9305                    Some(wh) => wh,
9306                    None => {
9307                        log::warn!("Image handle {} not found", handle);
9308                        continue;
9309                    }
9310                };
9311                let Some(source) = resolve_image_source(source_rect, img_w, img_h) else {
9312                    log::warn!(
9313                        "Image handle {} has invalid source rect {:?}",
9314                        handle,
9315                        source_rect
9316                    );
9317                    continue;
9318                };
9319                let Some((draw_rect, uv_rect)) = image_fit_geometry(rect, fit, alignment, &source)
9320                else {
9321                    continue;
9322                };
9323                let tile = matches!(fit, ImageFit::Tile);
9324                // Mirror the mesh path: backdrop-dependent modes isolate,
9325                // and so do the fixed-function ones when the source is
9326                // translucent (their blend states assume opaque source).
9327                let translucent_fixed_blend = matches!(
9328                    blend,
9329                    repose_core::BlendMode::Add
9330                        | repose_core::BlendMode::Multiply
9331                        | repose_core::BlendMode::Screen
9332                        | repose_core::BlendMode::Darken
9333                        | repose_core::BlendMode::Lighten
9334                ) && tint.3 < 255;
9335                if blend.needs_isolation() || translucent_fixed_blend {
9336                    if is_nv12 {
9337                        // NV12 sampling is only wired into the image
9338                        // pipeline; isolating would need an RGBA layer.
9339                        log::warn!("blend on an NV12 image is not supported");
9340                    } else {
9341                        let blend_scissor = to_scissor(
9342                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
9343                            current_target_size.0 as u32,
9344                            current_target_size.1 as u32,
9345                        );
9346                        self.emit_isolated_image(
9347                            draw_rect,
9348                            handle,
9349                            uv_rect,
9350                            tint,
9351                            tile,
9352                            filter,
9353                            blend,
9354                            current_transform,
9355                            &mut current_pass,
9356                            &active_clips,
9357                            &mut passes,
9358                            &mut flatten_id_head,
9359                            &mut next_pass_id,
9360                            &mut flatten_ids_used,
9361                            blend_scissor,
9362                            encoder,
9363                            fb_w,
9364                            fb_h,
9365                        );
9366                    }
9367                    continue;
9368                }
9369                let (ndc_center, fwd_mat) = rect_to_instance_ndc(
9370                    draw_rect,
9371                    current_transform,
9372                    current_target_size.0,
9373                    current_target_size.1,
9374                );
9375                if is_nv12 {
9376                    if image_run.is_some() {
9377                        flush_image_runs!();
9378                    }
9379                    let (uv_x_offset, uv_y_offset) =
9380                        if let Some(ImageTex::Nv12 {
9381                            w, h, color_info, ..
9382                        }) = self.images.get(&handle)
9383                        {
9384                            let chroma_w = w.div_ceil(2).max(1) as f32;
9385                            let chroma_h = h.div_ceil(2).max(1) as f32;
9386                            match color_info.chroma_siting {
9387                                ChromaSiting::Center => (0.0, 0.0),
9388                                ChromaSiting::Left => (-0.5 / chroma_w, 0.0),
9389                                ChromaSiting::TopLeft => (-0.5 / chroma_w, -0.5 / chroma_h),
9390                            }
9391                        } else {
9392                            (0.0, 0.0)
9393                        };
9394                    let inst = Nv12Instance {
9395                        xywh: ndc_center,
9396                        uv: uv_rect,
9397                        color: tint.to_linear(),
9398                        uv_x_offset,
9399                        uv_y_offset,
9400                        fwd_mat,
9401                    };
9402                    if nv12_run
9403                        .as_ref()
9404                        .is_some_and(|(run_handle, run_filter, run_blend, _)| {
9405                            *run_handle != handle || *run_filter != filter || *run_blend != blend
9406                        })
9407                    {
9408                        flush_image_runs!();
9409                    }
9410                    match &mut nv12_run {
9411                        Some((run_handle, run_filter, run_blend, instances))
9412                            if *run_handle == handle
9413                                && *run_filter == filter
9414                                && *run_blend == blend =>
9415                        {
9416                            instances.push(inst)
9417                        }
9418                        _ => nv12_run = Some((handle, filter, blend, vec![inst])),
9419                    }
9420                } else {
9421                    if nv12_run.is_some() {
9422                        flush_image_runs!();
9423                    }
9424                    let inst = GlyphInstance {
9425                        xywh: ndc_center,
9426                        uv: uv_rect,
9427                        color: tint.to_linear(),
9428                        fwd_mat,
9429                    };
9430                    if image_run.as_ref().is_some_and(
9431                        |(run_handle, run_filter, run_tile, run_blend, _)| {
9432                            *run_handle != handle
9433                                || *run_filter != filter
9434                                || *run_tile != tile
9435                                || *run_blend != blend
9436                        },
9437                    ) {
9438                        flush_image_runs!();
9439                    }
9440                    match &mut image_run {
9441                        Some((run_handle, run_filter, run_tile, run_blend, instances))
9442                            if *run_handle == handle
9443                                && *run_filter == filter
9444                                && *run_tile == tile
9445                                && *run_blend == blend =>
9446                        {
9447                            instances.push(inst)
9448                        }
9449                        _ => image_run = Some((handle, filter, tile, blend, vec![inst])),
9450                    }
9451                }
9452                continue;
9453            }
9454
9455            match node {
9456                SceneNode::Rect {
9457                    rect,
9458                    brush,
9459                    radius,
9460                } => {
9461                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9462                    if !visible(affine_aabb(current_transform, rect), clip) {
9463                        continue;
9464                    }
9465                    flush_if_prim_changed!("rect", &self.rects);
9466                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9467                        *rect,
9468                        current_transform,
9469                        current_target_size.0,
9470                        current_target_size.1,
9471                    );
9472                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9473                        brush_to_shape_fields(brush, rect, current_transform);
9474                    batch.rects.push(RectInstance {
9475                        xywh: ndc,
9476                        radii: radius.map(|r| r.0),
9477                        color0,
9478                        color1,
9479                        fwd_mat,
9480                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9481                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9482                        _pad: [0.0; 3],
9483                    });
9484                }
9485                SceneNode::Border {
9486                    rect,
9487                    brush,
9488                    width,
9489                    radius,
9490                } => {
9491                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9492                    if !visible(affine_aabb(current_transform, rect), clip) {
9493                        continue;
9494                    }
9495                    flush_if_prim_changed!("border", &self.borders);
9496                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9497                        *rect,
9498                        current_transform,
9499                        current_target_size.0,
9500                        current_target_size.1,
9501                    );
9502                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9503                        brush_to_shape_fields(brush, rect, current_transform);
9504                    batch.borders.push(BorderInstance {
9505                        xywh: ndc,
9506                        radii: radius.map(|r| r.0),
9507                        color0,
9508                        color1,
9509                        fwd_mat,
9510                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9511                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9512                        stroke: width.0,
9513                        _pad: [0.0; 2],
9514                    });
9515                }
9516                SceneNode::Ellipse { rect, brush } => {
9517                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9518                    if !visible(affine_aabb(current_transform, rect), clip) {
9519                        continue;
9520                    }
9521                    flush_if_prim_changed!("ellipse", &self.ellipses);
9522                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9523                        *rect,
9524                        current_transform,
9525                        current_target_size.0,
9526                        current_target_size.1,
9527                    );
9528                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9529                        brush_to_shape_fields(brush, rect, current_transform);
9530                    batch.ellipses.push(EllipseInstance {
9531                        xywh: ndc,
9532                        color0,
9533                        color1,
9534                        fwd_mat,
9535                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9536                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9537                        _pad: [0.0; 1],
9538                    });
9539                }
9540                SceneNode::EllipseBorder { rect, brush, width } => {
9541                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9542                    let cull_pad = width.0.abs() * 0.5 + 2.0;
9543                    let cull_rect = repose_core::Rect {
9544                        x: rect.x - cull_pad,
9545                        y: rect.y - cull_pad,
9546                        w: rect.w + cull_pad * 2.0,
9547                        h: rect.h + cull_pad * 2.0,
9548                    };
9549                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9550                        continue;
9551                    }
9552                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
9553                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9554                        *rect,
9555                        current_transform,
9556                        current_target_size.0,
9557                        current_target_size.1,
9558                    );
9559                    let pad_px = width.0 * 0.5 + 2.0;
9560                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9561                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9562                        brush_to_shape_fields(brush, rect, current_transform);
9563                    batch.e_borders.push(EllipseBorderInstance {
9564                        xywh: ndc,
9565                        color0,
9566                        color1,
9567                        fwd_mat,
9568                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9569                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9570                        stroke: width.0,
9571                        pad,
9572                        _pad: 0.0,
9573                    });
9574                }
9575                SceneNode::Arc {
9576                    rect,
9577                    start_angle,
9578                    sweep_angle,
9579                    stroke_width,
9580                    brush,
9581                    cap,
9582                } => {
9583                    if !start_angle.is_finite()
9584                        || !sweep_angle.is_finite()
9585                        || !stroke_width.0.is_finite()
9586                        || !rect.x.is_finite()
9587                        || !rect.y.is_finite()
9588                        || !rect.w.is_finite()
9589                        || !rect.h.is_finite()
9590                        || rect.w <= 0.0
9591                        || rect.h <= 0.0
9592                        || stroke_width.0 <= 0.0
9593                        || sweep_angle.abs() <= 1e-6
9594                    {
9595                        continue;
9596                    }
9597                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9598                    let cull_pad = stroke_width.0.abs() * 0.5 + 2.0;
9599                    let cull_rect = repose_core::Rect {
9600                        x: rect.x - cull_pad,
9601                        y: rect.y - cull_pad,
9602                        w: rect.w + cull_pad * 2.0,
9603                        h: rect.h + cull_pad * 2.0,
9604                    };
9605                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9606                        continue;
9607                    }
9608                    flush_if_prim_changed!("arc", &self.arcs);
9609                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9610                        *rect,
9611                        current_transform,
9612                        current_target_size.0.max(1.0),
9613                        current_target_size.1.max(1.0),
9614                    );
9615                    let pad_px = stroke_width.0 * 0.5 + 2.0;
9616                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9617                    let cap_val = match cap {
9618                        StrokeCap::Butt => 0.0,
9619                        StrokeCap::Round => 1.0,
9620                        StrokeCap::Square => 2.0,
9621                    };
9622                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9623                        brush_to_shape_fields(brush, rect, current_transform);
9624                    let (start, sweep) = if *sweep_angle < 0.0 {
9625                        (*start_angle + *sweep_angle, -*sweep_angle)
9626                    } else {
9627                        (*start_angle, *sweep_angle)
9628                    };
9629                    batch.arcs.push(ArcInstance {
9630                        xywh: ndc,
9631                        color0,
9632                        color1,
9633                        fwd_mat,
9634                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9635                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, cap_val as u32),
9636                        start_angle: start,
9637                        sweep_angle: sweep,
9638                        stroke: stroke_width.0,
9639                        pad,
9640                        _pad: 0.0,
9641                    });
9642                }
9643                SceneNode::Text {
9644                    rect,
9645                    text,
9646                    color,
9647                    size,
9648                    style,
9649                } => {
9650                    let TextPaintStyle {
9651                        font_family,
9652                        font_weight,
9653                        font_style,
9654                        text_decoration,
9655                        letter_spacing,
9656                        font_variation_settings,
9657                        baseline_shift,
9658                        draw_style,
9659                        ..
9660                    } = style;
9661                    let px = size.0;
9662                    if !px.is_finite()
9663                        || px <= 0.0
9664                        || !rect.x.is_finite()
9665                        || !rect.y.is_finite()
9666                        || !rect.w.is_finite()
9667                        || !rect.h.is_finite()
9668                    {
9669                        continue;
9670                    }
9671                    let margin = px * 4.0 + letter_spacing.0.abs() * 2.0;
9672                    let text_bounds = repose_core::Rect {
9673                        x: rect.x - margin,
9674                        y: rect.y - margin,
9675                        w: rect.w + margin * 2.0,
9676                        h: rect.h + margin * 2.0,
9677                    };
9678                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9679                    if !visible(affine_aabb(current_transform, &text_bounds), clip) {
9680                        continue;
9681                    }
9682                    flush_batch!(); // flush any prior primitives
9683
9684                    let lh_ratio = rect.h / px;
9685                    let fw = font_weight.0;
9686                    let fs = if *font_style == FontStyle::Italic {
9687                        1
9688                    } else {
9689                        0
9690                    };
9691                    let shaped = self.shape_text_cached(
9692                        text,
9693                        px,
9694                        lh_ratio,
9695                        *font_family,
9696                        fw,
9697                        fs,
9698                        letter_spacing.0,
9699                        font_variation_settings.as_ref(),
9700                    );
9701                    let baseline_y = shaped.glyphs.first().map(|g| rect.y + g.y);
9702
9703                    let fwd = forward_rs_mat(current_transform);
9704                    let has_linear = fwd != [1.0, 0.0, 0.0, 1.0];
9705
9706                    let lin = current_transform.linear();
9707                    let tr_x = current_transform.translate_x;
9708                    let tr_y = current_transform.translate_y;
9709
9710                    let make_glyph_instance =
9711                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 4]) {
9712                            if has_linear {
9713                                let gc_x = gx + gw * 0.5;
9714                                let gc_y = gy + gh * 0.5;
9715                                let wc_x = lin[0] * gc_x + lin[1] * gc_y + tr_x;
9716                                let wc_y = lin[2] * gc_x + lin[3] * gc_y + tr_y;
9717                                let ww = gw * current_transform.scale_x;
9718                                let wh = gh * current_transform.scale_y;
9719                                let ex = fwd[0].abs() * ww * 0.5 + fwd[1].abs() * wh * 0.5;
9720                                let ey = fwd[2].abs() * ww * 0.5 + fwd[3].abs() * wh * 0.5;
9721                                let ndc_tl = to_ndc(
9722                                    wc_x - ex,
9723                                    wc_y - ey,
9724                                    ex * 2.0,
9725                                    ey * 2.0,
9726                                    current_target_size.0,
9727                                    current_target_size.1,
9728                                );
9729                                let ndc = [
9730                                    ndc_tl[0] + ndc_tl[2] * 0.5,
9731                                    ndc_tl[1] + ndc_tl[3] * 0.5,
9732                                    ndc_tl[2],
9733                                    ndc_tl[3],
9734                                ];
9735                                (ndc, fwd)
9736                            } else {
9737                                let (sx, sy) = if current_transform.scale_x == 1.0
9738                                    && current_transform.scale_y == 1.0
9739                                {
9740                                    (gx.round(), gy.round())
9741                                } else {
9742                                    (gx, gy)
9743                                };
9744                                rect_to_instance_ndc(
9745                                    repose_core::Rect {
9746                                        x: sx,
9747                                        y: sy,
9748                                        w: gw,
9749                                        h: gh,
9750                                    },
9751                                    current_transform,
9752                                    current_target_size.0,
9753                                    current_target_size.1,
9754                                )
9755                            }
9756                        };
9757
9758                    let baseline_shift_y: f32 = px * baseline_shift.0;
9759
9760                    let (
9761                        draws_fill,
9762                        is_stroke,
9763                        stroke_width,
9764                        stroke_cap,
9765                        stroke_join,
9766                        stroke_miter,
9767                        stroke_path_effect,
9768                        stroke_color,
9769                    ) = match draw_style {
9770                        repose_core::DrawStyle::Stroke {
9771                            width,
9772                            cap,
9773                            join,
9774                            miter,
9775                            path_effect,
9776                        } => (
9777                            false,
9778                            true,
9779                            *width,
9780                            *cap,
9781                            *join,
9782                            *miter,
9783                            path_effect.clone(),
9784                            None,
9785                        ),
9786                        repose_core::DrawStyle::FillAndStroke {
9787                            width,
9788                            cap,
9789                            join,
9790                            miter,
9791                            path_effect,
9792                            outline_color,
9793                        } => (
9794                            true,
9795                            true,
9796                            *width,
9797                            *cap,
9798                            *join,
9799                            *miter,
9800                            path_effect.clone(),
9801                            // The outline is part of this text's appearance,
9802                            // so it fades with the text alpha like the fill.
9803                            // Without this a faded label keeps a solid halo.
9804                            outline_color.map(|c| {
9805                                c.with_alpha_f32(
9806                                    f32::from(c.3) * f32::from(color.3) / (255.0 * 255.0),
9807                                )
9808                            }),
9809                        ),
9810                        _ => (
9811                            true,
9812                            false,
9813                            0.0,
9814                            repose_core::StrokeCap::Butt,
9815                            repose_core::StrokeJoin::Miter,
9816                            4.0,
9817                            None,
9818                            None,
9819                        ),
9820                    };
9821                    // A contrasting outline reads as a halo, so it goes under
9822                    // the fill; a same-colour stroke is faux-bold and stays on
9823                    // top.
9824                    let outline_under = stroke_color.is_some();
9825                    let stroke_tess_key = if is_stroke {
9826                        Some(slug::StrokeTessKey::new(
9827                            stroke_width,
9828                            stroke_cap,
9829                            stroke_join,
9830                            stroke_miter,
9831                            &stroke_path_effect,
9832                        ))
9833                    } else {
9834                        None
9835                    };
9836
9837                    let mut legacy_shaped = None;
9838                    for (glyph_index, sg) in shaped.glyphs.iter().enumerate() {
9839                        if self.slug_enabled {
9840                            let ck = &sg.cache_key;
9841                            let color_linear = color.to_linear();
9842                            let stroke_color_linear = stroke_color.unwrap_or(*color).to_linear();
9843                            let glyph_pos = (rect.x + sg.x, rect.y + sg.y + baseline_shift_y);
9844                            let fill_key = draws_fill.then(|| {
9845                                Self::slug_draw_key(
9846                                    ck,
9847                                    true,
9848                                    None,
9849                                    current_transform,
9850                                    glyph_pos,
9851                                    px,
9852                                    current_target_size,
9853                                    color_linear,
9854                                )
9855                            });
9856                            let stroke_key = is_stroke.then(|| {
9857                                Self::slug_draw_key(
9858                                    ck,
9859                                    false,
9860                                    stroke_tess_key.as_ref(),
9861                                    current_transform,
9862                                    glyph_pos,
9863                                    px,
9864                                    current_target_size,
9865                                    stroke_color_linear,
9866                                )
9867                            });
9868
9869                            // The draw cache outlives the frame, so probing both keys up
9870                            // front lets a steady-state glyph clone neither buffer.
9871                            let fill_cache = fill_key
9872                                .as_ref()
9873                                .and_then(|k| self.get_cached_slug_vertices(k));
9874                            let stroke_cache = stroke_key
9875                                .as_ref()
9876                                .and_then(|k| self.get_cached_slug_vertices(k));
9877                            let fill_miss = fill_key.is_some() && fill_cache.is_none();
9878                            let stroke_miss = stroke_key.is_some() && stroke_cache.is_none();
9879                            if (fill_key.is_some() || stroke_key.is_some())
9880                                && !fill_miss
9881                                && !stroke_miss
9882                            {
9883                                let mut push = |cached: &Option<Arc<[slug::TessVertex]>>| {
9884                                    if let Some(vertices) = cached {
9885                                        slug_verts_local.extend_from_slice(vertices);
9886                                    }
9887                                };
9888                                if outline_under {
9889                                    push(&stroke_cache);
9890                                    push(&fill_cache);
9891                                } else {
9892                                    push(&fill_cache);
9893                                    push(&stroke_cache);
9894                                }
9895                                continue;
9896                            }
9897
9898                            let outline = self.cached_glyph_outline(ck);
9899                            if let Some(commands) = outline.as_ref() {
9900                                let font_size_px = f32::from_bits(ck.font_size_bits);
9901                                if draws_fill {
9902                                    self.slug_cache.get_or_insert(
9903                                        ck.clone(),
9904                                        font_size_px,
9905                                        commands.as_ref(),
9906                                    );
9907                                }
9908                                if is_stroke {
9909                                    self.slug_cache.get_or_insert_stroke(
9910                                        ck.clone(),
9911                                        font_size_px,
9912                                        commands.as_ref(),
9913                                        stroke_width,
9914                                        stroke_cap,
9915                                        stroke_join,
9916                                        stroke_miter,
9917                                        &stroke_path_effect,
9918                                    );
9919                                }
9920                            }
9921
9922                            if let Some(entry) = self.slug_cache.get(ck) {
9923                                let ox = rect.x + sg.x;
9924                                let oy = rect.y + sg.y + baseline_shift_y;
9925                                let tf = |x: f32, y: f32| -> (f32, f32) {
9926                                    if has_linear {
9927                                        (
9928                                            lin[0] * x + lin[1] * y + current_transform.translate_x,
9929                                            lin[2] * x + lin[3] * y + current_transform.translate_y,
9930                                        )
9931                                    } else {
9932                                        (
9933                                            x * current_transform.scale_x
9934                                                + current_transform.translate_x,
9935                                            y * current_transform.scale_y
9936                                                + current_transform.translate_y,
9937                                        )
9938                                    }
9939                                };
9940                                let tw = current_target_size.0;
9941                                let th = current_target_size.1;
9942                                let fill_vertices = if draws_fill && fill_cache.is_none() {
9943                                    entry.fill_vertices.clone().unwrap_or_default()
9944                                } else {
9945                                    Vec::new()
9946                                };
9947                                let stroke_vertices = if stroke_cache.is_none() {
9948                                    stroke_tess_key
9949                                        .as_ref()
9950                                        .and_then(|key| entry.stroke_variants.get(key))
9951                                        .map(|variant| variant.vertices.clone())
9952                                        .unwrap_or_default()
9953                                } else {
9954                                    Vec::new()
9955                                };
9956                                let mut emit =
9957                                    |verts: &Vec<[f32; 2]>,
9958                                     key: &Option<SlugDrawKey>,
9959                                     cached: &Option<Arc<[slug::TessVertex]>>,
9960                                     vertex_color: [f32; 4]| {
9961                                        if let Some(vertices) = cached {
9962                                            slug_verts_local.extend_from_slice(vertices);
9963                                            return;
9964                                        }
9965                                        let Some(key) = key.clone() else {
9966                                            return;
9967                                        };
9968                                        let transformed: Vec<slug::TessVertex> = verts
9969                                            .iter()
9970                                            .map(|v| {
9971                                                let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
9972                                                slug::TessVertex {
9973                                                    ndc_pos: [
9974                                                        sx / tw * 2.0 - 1.0,
9975                                                        -(sy / th) * 2.0 + 1.0,
9976                                                    ],
9977                                                    color: vertex_color,
9978                                                }
9979                                            })
9980                                            .collect();
9981                                        let vertices = self.cached_slug_vertices(key, transformed);
9982                                        slug_verts_local.extend_from_slice(&vertices);
9983                                    };
9984                                // A contrasting outline reads as a halo and
9985                                // goes under the fill; a same-colour stroke is
9986                                // faux-bold and stays on top.
9987                                if outline_under {
9988                                    emit(
9989                                        &stroke_vertices,
9990                                        &stroke_key,
9991                                        &stroke_cache,
9992                                        stroke_color_linear,
9993                                    );
9994                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
9995                                } else {
9996                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
9997                                    emit(
9998                                        &stroke_vertices,
9999                                        &stroke_key,
10000                                        &stroke_cache,
10001                                        stroke_color_linear,
10002                                    );
10003                                }
10004                                continue;
10005                            }
10006                        }
10007
10008                        if !draws_fill {
10009                            continue;
10010                        }
10011                        if legacy_shaped.is_none() {
10012                            legacy_shaped = Some(self.legacy_shape_text_cached(
10013                                text,
10014                                px,
10015                                lh_ratio,
10016                                *font_family,
10017                                fw,
10018                                fs,
10019                                letter_spacing.0,
10020                                font_variation_settings.as_ref(),
10021                            ));
10022                        }
10023                        let Some(legacy_glyph) = legacy_shaped
10024                            .as_ref()
10025                            .and_then(|glyphs| glyphs.get(glyph_index))
10026                        else {
10027                            continue;
10028                        };
10029                        let gx = rect.x + legacy_glyph.x + legacy_glyph.bearing_x;
10030                        let gy =
10031                            rect.y + legacy_glyph.y - legacy_glyph.bearing_y + baseline_shift_y;
10032                        if let Some(info) =
10033                            self.upload_glyph_color(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10034                        {
10035                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10036                            batch.colors.push(GlyphInstance {
10037                                xywh: ndc,
10038                                uv: [info.u0, info.v1, info.u1, info.v0],
10039                                color: color.to_linear(),
10040                                fwd_mat,
10041                            });
10042                        } else if let Some(info) =
10043                            self.upload_glyph_mask(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10044                        {
10045                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10046                            batch.masks.push(GlyphInstance {
10047                                xywh: ndc,
10048                                uv: [info.u0, info.v1, info.u1, info.v0],
10049                                color: color.to_linear(),
10050                                fwd_mat,
10051                            });
10052                        }
10053                    }
10054
10055                    // Upload slug vertices if any
10056                    if !slug_verts_local.is_empty() {
10057                        let bytes = bytemuck::cast_slice(&slug_verts_local);
10058                        let byte_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
10059                        let Ok(count) = u32::try_from(slug_verts_local.len()) else {
10060                            slug_verts_local.clear();
10061                            continue;
10062                        };
10063                        if self
10064                            .slug_ring
10065                            .grow_to_fit(&self.device, encoder, byte_len)
10066                            .is_ok()
10067                            && let Ok(off) = self.slug_ring.alloc_write(&self.queue, bytes)
10068                        {
10069                            current_pass
10070                                .cmds
10071                                .push(Cmd::GlyphsVector { off, cnt: count });
10072                        }
10073                        slug_verts_local.clear();
10074                    }
10075
10076                    // Text decoration: underline / strikethrough
10077                    if (text_decoration.underline || text_decoration.strikethrough)
10078                        && let Some(baseline_y) = baseline_y
10079                    {
10080                        flush_batch!();
10081                        current_prim = Some("rect");
10082                        let deco_color = text_decoration.color.unwrap_or(*color);
10083                        let thickness = (px * 0.07).max(1.0);
10084
10085                        if text_decoration.underline {
10086                            let dy = baseline_y + px * 0.1;
10087                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10088                                repose_core::Rect {
10089                                    x: rect.x,
10090                                    y: dy,
10091                                    w: rect.w,
10092                                    h: thickness,
10093                                },
10094                                current_transform,
10095                                current_target_size.0,
10096                                current_target_size.1,
10097                            );
10098                            batch.rects.push(RectInstance {
10099                                xywh: ndc,
10100                                radii: [0.0; 4],
10101                                color0: deco_color.to_linear(),
10102                                color1: [0.0; 4],
10103                                fwd_mat,
10104                                grad: [0.0; 4],
10105                                bits: 0,
10106                                _pad: [0.0; 3],
10107                            });
10108                        }
10109                        if text_decoration.strikethrough {
10110                            let sy = baseline_y - px * 0.3;
10111                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10112                                repose_core::Rect {
10113                                    x: rect.x,
10114                                    y: sy,
10115                                    w: rect.w,
10116                                    h: thickness,
10117                                },
10118                                current_transform,
10119                                current_target_size.0,
10120                                current_target_size.1,
10121                            );
10122                            batch.rects.push(RectInstance {
10123                                xywh: ndc,
10124                                radii: [0.0; 4],
10125                                color0: deco_color.to_linear(),
10126                                color1: [0.0; 4],
10127                                fwd_mat,
10128                                grad: [0.0; 4],
10129                                bits: 0,
10130                                _pad: [0.0; 3],
10131                            });
10132                        }
10133                    }
10134                }
10135                SceneNode::Coverage {
10136                    rect,
10137                    handle,
10138                    color,
10139                } => {
10140                    flush_batch!();
10141                    // Unknown handles are skipped (same policy as images);
10142                    // the lookup also marks the tile used for eviction.
10143                    let Some((tile_w, tile_h)) = self.coverage_dimensions(*handle) else {
10144                        log::warn!("Coverage handle {handle} not found");
10145                        continue;
10146                    };
10147                    // The tile composites at its registered size; `rect`
10148                    // positions its top-left.
10149                    let draw_rect = repose_core::Rect {
10150                        x: rect.x,
10151                        y: rect.y,
10152                        w: tile_w as f32,
10153                        h: tile_h as f32,
10154                    };
10155                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10156                    if !visible(affine_aabb(current_transform, &draw_rect), clip) {
10157                        continue;
10158                    }
10159                    let (ndc_center, fwd_mat) = rect_to_instance_ndc(
10160                        draw_rect,
10161                        current_transform,
10162                        current_target_size.0,
10163                        current_target_size.1,
10164                    );
10165                    let inst = GlyphInstance {
10166                        xywh: ndc_center,
10167                        uv: [0.0, 1.0, 1.0, 0.0],
10168                        color: color.to_linear(),
10169                        fwd_mat,
10170                    };
10171                    if let Some((off, _)) =
10172                        self.glyph_color
10173                            .upload(&self.device, &self.queue, encoder, &[inst])
10174                    {
10175                        current_pass.cmds.push(Cmd::Coverage {
10176                            off,
10177                            cnt: 1,
10178                            handle: *handle,
10179                        });
10180                    }
10181                }
10182                SceneNode::PushClip { rect, radius, op } => {
10183                    flush_batch!();
10184
10185                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
10186                    let t_identity = Transform::identity();
10187                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10188                    let transformed = affine_aabb(current_transform, rect);
10189                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10190                    let next_scissor = if is_diff {
10191                        top
10192                    } else {
10193                        intersect(top, transformed)
10194                    };
10195                    scissor_stack.push(next_scissor);
10196                    let scissor = to_scissor(
10197                        &next_scissor,
10198                        current_target_size.0 as u32,
10199                        current_target_size.1 as u32,
10200                    );
10201                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10202                    let mut blocked =
10203                        current_pass.active_scissor.is_none() || (has_inverse && !is_diff);
10204                    if has_inverse && is_diff {
10205                        log::error!(
10206                            "unsupported clip nesting: Difference cannot contain a Difference clip"
10207                        );
10208                        blocked = true;
10209                    }
10210                    let has_area = transformed.x.is_finite()
10211                        && transformed.y.is_finite()
10212                        && transformed.w.is_finite()
10213                        && transformed.h.is_finite()
10214                        && transformed.w > 0.0
10215                        && transformed.h > 0.0;
10216                    let mut applied = false;
10217                    let mut off = 0;
10218                    if !blocked && has_area && (is_diff || scissor.2 > 0 && scissor.3 > 0) {
10219                        let clip_ndc_tl = to_ndc(
10220                            transformed.x,
10221                            transformed.y,
10222                            transformed.w,
10223                            transformed.h,
10224                            current_target_size.0,
10225                            current_target_size.1,
10226                        );
10227                        let inst = ClipInstance {
10228                            xywh: [
10229                                clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
10230                                clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
10231                                clip_ndc_tl[2],
10232                                clip_ndc_tl[3],
10233                            ],
10234                            radii: radius.map(|r| r.0),
10235                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10236                        };
10237                        let bytes = bytemuck::bytes_of(&inst);
10238                        if self
10239                            .clip_ring
10240                            .grow_to_fit(
10241                                &self.device,
10242                                encoder,
10243                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
10244                            )
10245                            .is_ok()
10246                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
10247                        {
10248                            off = new_off;
10249                            applied = true;
10250                            current_pass.cmds.push(Cmd::ClipPush {
10251                                off,
10252                                cnt: 1,
10253                                scissor,
10254                                difference: is_diff,
10255                                applied,
10256                            });
10257                        } else {
10258                            blocked = !is_diff;
10259                        }
10260                    } else if !is_diff {
10261                        blocked = true;
10262                    }
10263                    current_pass.active_scissor = if blocked {
10264                        None
10265                    } else {
10266                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10267                    };
10268                    active_clips.push(ActiveClip::Rect {
10269                        off,
10270                        cnt: u32::from(applied),
10271                        rect: transformed,
10272                        radii: radius.map(|r| r.0),
10273                        scissor,
10274                        difference: is_diff,
10275                        applied,
10276                        blocked,
10277                    });
10278                }
10279                SceneNode::PopClip => {
10280                    flush_batch!();
10281
10282                    if scissor_stack.is_empty() || active_clips.is_empty() {
10283                        log::error!("PopClip does not match an active clip");
10284                    } else {
10285                        scissor_stack.pop();
10286                    }
10287                    let clip = active_clips.pop();
10288                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10289                    let scissor = to_scissor(
10290                        &top,
10291                        current_target_size.0 as u32,
10292                        current_target_size.1 as u32,
10293                    );
10294                    current_pass.active_scissor =
10295                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10296                    match clip {
10297                        Some(ActiveClip::Rect {
10298                            off,
10299                            cnt,
10300                            scissor: stencil_scissor,
10301                            difference,
10302                            applied,
10303                            ..
10304                        }) if applied && cnt > 0 => {
10305                            current_pass.cmds.push(Cmd::ClipPop {
10306                                off,
10307                                cnt,
10308                                scissor,
10309                                stencil_scissor,
10310                                difference,
10311                                applied: true,
10312                            });
10313                        }
10314                        Some(ActiveClip::Vector { .. }) => {
10315                            log::error!("PopClip matched a vector clip");
10316                        }
10317                        None => {}
10318                        _ => {}
10319                    }
10320                }
10321                SceneNode::Shadow {
10322                    rect,
10323                    radius,
10324                    elevation: _,
10325                    color,
10326                } => {
10327                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10328                    if !visible(affine_aabb(current_transform, rect), clip) {
10329                        continue;
10330                    }
10331                    flush_if_prim_changed!("rect", &self.rects);
10332                    let (ndc, fwd_mat) = rect_to_instance_ndc(
10333                        *rect,
10334                        current_transform,
10335                        current_target_size.0,
10336                        current_target_size.1,
10337                    );
10338                    let (_brush_type, color0, _color1, _grad_p0, _grad_p1) =
10339                        brush_to_instance_fields(&Brush::Solid(*color));
10340                    batch.rects.push(RectInstance {
10341                        xywh: ndc,
10342                        radii: radius.map(|r| r.0),
10343                        color0,
10344                        color1: [0.0; 4],
10345                        fwd_mat,
10346                        grad: [0.0; 4],
10347                        bits: 0,
10348                        _pad: [0.0; 3],
10349                    });
10350                }
10351                SceneNode::PushTransform { transform } => {
10352                    flush_batch!(); // flush before transform change
10353                    if transform.has_perspective() {
10354                        // True perspective cannot ride the affine fast path:
10355                        // flatten the subtree into an offscreen layer and
10356                        // composite it back projectively (CSS-style). See
10357                        // `push_perspective_layer`.
10358                        let top = *transform_stack.last().unwrap_or(&t_identity);
10359                        self.push_perspective_layer(
10360                            *transform,
10361                            top,
10362                            &mut transform_stack,
10363                            &mut scissor_stack,
10364                            &mut root_clip_rect,
10365                            &mut current_target_size,
10366                            &mut current_pass,
10367                            &mut active_clips,
10368                            &mut passes,
10369                            &mut target_stack,
10370                            &mut flatten_stack,
10371                            &mut flatten_id_head,
10372                            &mut next_pass_id,
10373                            &mut flatten_ids_used,
10374                            encoder,
10375                        );
10376                    } else {
10377                        let combined = current_transform.combine(transform);
10378                        transform_stack.push(combined);
10379                    }
10380                }
10381                SceneNode::PopTransform => {
10382                    flush_batch!(); // flush before transform change
10383                    if let Some(rec) = flatten_stack.last() {
10384                        // A flatten level closes when the stack is back to the
10385                        // two entries this flatten pushed (stripped transform +
10386                        // layer-local shift); deeper plain pushes close first.
10387                        if transform_stack.len() == rec.stack_len + 2 {
10388                            let rec = flatten_stack.pop().expect("checked above");
10389                            transform_stack.pop();
10390                            transform_stack.pop();
10391                            self.pop_perspective_layer(
10392                                rec,
10393                                &mut scissor_stack,
10394                                &mut root_clip_rect,
10395                                &mut current_target_size,
10396                                &mut current_pass,
10397                                &mut active_clips,
10398                                &mut passes,
10399                                &mut target_stack,
10400                                &mut next_pass_id,
10401                                encoder,
10402                            );
10403                            continue;
10404                        }
10405                    }
10406                    transform_stack.pop();
10407                }
10408                SceneNode::BeginLayer {
10409                    rect,
10410                    layer_id,
10411                    alpha,
10412                    blur_radius_x,
10413                    blur_radius_y,
10414                    rectangle_edge,
10415                } => {
10416                    flush_batch!();
10417                    let width_f = rect.w.round();
10418                    let height_f = rect.h.round();
10419                    let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
10420                    if !width_f.is_finite()
10421                        || !height_f.is_finite()
10422                        || width_f < 1.0
10423                        || height_f < 1.0
10424                        || width_f > max_dimension
10425                        || height_f > max_dimension
10426                    {
10427                        log::warn!("BeginLayer dimensions are invalid");
10428                        continue;
10429                    }
10430                    let width = width_f as u32;
10431                    let height = height_f as u32;
10432                    let parent_scissors =
10433                        std::mem::replace(&mut scissor_stack, Vec::with_capacity(8));
10434                    let parent_root_clip = std::mem::replace(
10435                        &mut root_clip_rect,
10436                        repose_core::Rect {
10437                            x: 0.0,
10438                            y: 0.0,
10439                            w: width as f32,
10440                            h: height as f32,
10441                        },
10442                    );
10443                    scissor_stack.push(root_clip_rect);
10444                    let layer_pass_id = next_pass_id;
10445                    next_pass_id += 1;
10446                    let parent_clips = std::mem::take(&mut active_clips);
10447                    let mut layer_pass = Pass {
10448                        id: layer_pass_id,
10449                        target: PassTarget::Layer(*layer_id),
10450                        initial_scissor: (0, 0, width, height),
10451                        active_scissor: Some((0, 0, width, height)),
10452                        clear_color: Some([0.0, 0.0, 0.0, 0.0]),
10453                        cmds: Vec::new(),
10454                    };
10455                    let parent_transform = *current_transform;
10456                    active_clips = self.replay_active_clips(
10457                        &parent_clips,
10458                        &parent_transform,
10459                        (rect.x, rect.y),
10460                        (width as f32, height as f32),
10461                        &mut layer_pass,
10462                        encoder,
10463                    );
10464                    let saved = std::mem::replace(&mut current_pass, layer_pass);
10465                    if let Some(top) = transform_stack.last_mut() {
10466                        *top = Transform::identity();
10467                    }
10468                    layer_stack.push(LayerState {
10469                        layer_id: *layer_id,
10470                        parent_target: saved.target,
10471                        parent_scissors,
10472                        parent_root_clip,
10473                        parent_size: current_target_size,
10474                        parent_transform,
10475                        parent_scissor: saved.active_scissor,
10476                        parent_clips,
10477                        alpha: *alpha,
10478                        blur: (blur_radius_x.0, blur_radius_y.0),
10479                        rectangle_edge: *rectangle_edge,
10480                    });
10481                    passes.push(saved);
10482                    if self.get_or_create_layer(*layer_id, width, height, *rect, false) {
10483                        self.producer_layer_ids.insert(*layer_id);
10484                    }
10485                    current_target_size = (width as f32, height as f32);
10486                }
10487                SceneNode::EndLayer { layer_id } => {
10488                    flush_batch!();
10489                    if layer_stack.last().map(|state| state.layer_id) != Some(*layer_id) {
10490                        log::warn!("EndLayer {} does not match active layer", layer_id);
10491                        continue;
10492                    }
10493                    let state = layer_stack.pop().expect("checked above");
10494                    scissor_stack = state.parent_scissors;
10495                    root_clip_rect = state.parent_root_clip;
10496                    active_clips = state.parent_clips;
10497                    if let Some(top) = transform_stack.last_mut() {
10498                        *top = state.parent_transform;
10499                    }
10500                    current_target_size = state.parent_size;
10501                    let resumed_pass_id = next_pass_id;
10502                    next_pass_id += 1;
10503                    let saved = std::mem::replace(
10504                        &mut current_pass,
10505                        Pass {
10506                            id: resumed_pass_id,
10507                            target: state.parent_target,
10508                            initial_scissor: state.parent_scissor.unwrap_or((0, 0, 1, 1)),
10509                            active_scissor: state.parent_scissor,
10510                            clear_color: None,
10511                            cmds: Vec::new(),
10512                        },
10513                    );
10514                    passes.push(saved);
10515                    let Some(layer) = self.layer_pool.get(layer_id).cloned() else {
10516                        continue;
10517                    };
10518                    let layer_rect = repose_core::Rect {
10519                        x: layer.rect_px.0,
10520                        y: layer.rect_px.1,
10521                        w: layer.rect_px.2,
10522                        h: layer.rect_px.3,
10523                    };
10524                    let local_layer_rect = affine_aabb(&state.parent_transform, &layer_rect);
10525                    let (parent_width, parent_height) = state.parent_size;
10526                    let parent_clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10527                    let composite_rect = if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10528                        let pad_x = blur_pad(state.blur.0);
10529                        let pad_y = blur_pad(state.blur.1);
10530                        repose_core::Rect {
10531                            x: local_layer_rect.x - pad_x,
10532                            y: local_layer_rect.y - pad_y,
10533                            w: local_layer_rect.w + pad_x * 2.0,
10534                            h: local_layer_rect.h + pad_y * 2.0,
10535                        }
10536                    } else {
10537                        local_layer_rect
10538                    };
10539                    let shadow_follows = shadow_layer_ids.contains(layer_id);
10540                    if !visible(composite_rect, parent_clip) {
10541                        continue;
10542                    }
10543                    let mut composite = None;
10544                    if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10545                        flush_batch!();
10546                        let pad_x = blur_pad(state.blur.0);
10547                        let pad_y = blur_pad(state.blur.1);
10548                        let rect = repose_core::Rect {
10549                            x: local_layer_rect.x - pad_x,
10550                            y: local_layer_rect.y - pad_y,
10551                            w: local_layer_rect.w + pad_x * 2.0,
10552                            h: local_layer_rect.h + pad_y * 2.0,
10553                        };
10554                        let ndc =
10555                            to_ndc(rect.x, rect.y, rect.w, rect.h, parent_width, parent_height);
10556                        let edge_mode = if state.rectangle_edge { 0 } else { 1 };
10557                        let sigma = [blur_sigma(state.blur.0), blur_sigma(state.blur.1)];
10558                        if let Some(scratch_id) = self.emit_separable_blur(
10559                            *layer_id,
10560                            BLUR_SLOT_CONTENT,
10561                            sigma,
10562                            (pad_x, pad_y),
10563                            edge_mode,
10564                            false,
10565                            [0.0; 4],
10566                            &mut passes,
10567                            &mut current_pass,
10568                            &mut next_pass_id,
10569                            encoder,
10570                        ) {
10571                            let inst = BlurInstance {
10572                                xywh: [
10573                                    ndc[0] + ndc[2] * 0.5,
10574                                    ndc[1] + ndc[3] * 0.5,
10575                                    ndc[2],
10576                                    ndc[3],
10577                                ],
10578                                uv: [0.0, 0.0, 1.0, 1.0],
10579                                color: [1.0, 1.0, 1.0, state.alpha],
10580                                sigma_px: sigma,
10581                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10582                                axis: 1,
10583                                edge_mode,
10584                                shape: [0.0; 4],
10585                            };
10586                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10587                                composite = Some(Cmd::CompositeBlur {
10588                                    off,
10589                                    cnt: 1,
10590                                    layer_id: scratch_id,
10591                                });
10592                            }
10593                        }
10594                    }
10595                    // No scratch (transient budget exhausted) leaves the layer
10596                    // sharp rather than dropped.
10597                    if composite.is_none() {
10598                        let ndc = to_ndc(
10599                            local_layer_rect.x,
10600                            local_layer_rect.y,
10601                            local_layer_rect.w,
10602                            local_layer_rect.h,
10603                            parent_width,
10604                            parent_height,
10605                        );
10606                        let inst = GlyphInstance {
10607                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
10608                            uv: [0.0, 1.0, 1.0, 0.0],
10609                            color: [1.0, 1.0, 1.0, state.alpha],
10610                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10611                        };
10612                        composite = self
10613                            .glyph_color
10614                            .upload(&self.device, &self.queue, encoder, &[inst])
10615                            .map(|(off, cnt)| Cmd::CompositeLayer {
10616                                off,
10617                                cnt,
10618                                layer_id: *layer_id,
10619                            });
10620                    }
10621                    if let Some(command) = composite {
10622                        if shadow_follows {
10623                            pending_layer_composite = Some(command);
10624                        } else {
10625                            current_pass.cmds.push(command);
10626                        }
10627                    }
10628                }
10629                SceneNode::CompositeShadow {
10630                    layer_id,
10631                    blur_px,
10632                    offset_px,
10633                    color,
10634                    shape_px,
10635                } => {
10636                    let sigma = blur_sigma(blur_px.0);
10637                    let pad = blur_pad(blur_px.0);
10638                    let layer = self.layer_pool.get(layer_id).cloned();
10639                    if let Some(layer) = layer {
10640                        let layer_rect = repose_core::Rect {
10641                            x: layer.rect_px.0,
10642                            y: layer.rect_px.1,
10643                            w: layer.rect_px.2,
10644                            h: layer.rect_px.3,
10645                        };
10646                        let local_layer_rect =
10647                            affine_aabb(transform_stack.last().unwrap_or(&t_identity), &layer_rect);
10648                        let sx = local_layer_rect.x + offset_px.0.0 - pad;
10649                        let sy = local_layer_rect.y + offset_px.1.0 - pad;
10650                        let sw = local_layer_rect.w + pad * 2.0;
10651                        let sh = local_layer_rect.h + pad * 2.0;
10652                        let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10653                        if !visible(
10654                            repose_core::Rect {
10655                                x: sx,
10656                                y: sy,
10657                                w: sw,
10658                                h: sh,
10659                            },
10660                            clip,
10661                        ) {
10662                            flush_pending_layer!();
10663                            continue;
10664                        }
10665                        flush_batch!();
10666                        let shadow_color = color.to_linear();
10667                        let max_r = 0.5 * layer.rect_px.2.min(layer.rect_px.3).max(0.0);
10668                        let shape = shape_px.map(|r| r.0.clamp(0.0, max_r));
10669                        // Decal edges: a drop shadow's falloff lives outside the
10670                        // source's own bounds, so out-of-range taps must
10671                        // contribute nothing rather than clamp to the edge texel.
10672                        let scratch_id = self.emit_separable_blur(
10673                            *layer_id,
10674                            BLUR_SLOT_SHADOW,
10675                            [sigma, sigma],
10676                            (pad, pad),
10677                            1,
10678                            true,
10679                            shape,
10680                            &mut passes,
10681                            &mut current_pass,
10682                            &mut next_pass_id,
10683                            encoder,
10684                        );
10685                        if let Some(scratch_id) = scratch_id {
10686                            let ndc_tl = to_ndc(
10687                                sx,
10688                                sy,
10689                                sw,
10690                                sh,
10691                                current_target_size.0,
10692                                current_target_size.1,
10693                            );
10694                            let inst = BlurInstance {
10695                                xywh: [
10696                                    ndc_tl[0] + ndc_tl[2] * 0.5,
10697                                    ndc_tl[1] + ndc_tl[3] * 0.5,
10698                                    ndc_tl[2],
10699                                    ndc_tl[3],
10700                                ],
10701                                uv: [0.0, 0.0, 1.0, 1.0],
10702                                color: shadow_color,
10703                                sigma_px: [sigma, sigma],
10704                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10705                                axis: 1,
10706                                edge_mode: 1,
10707                                shape: [0.0; 4],
10708                            };
10709                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10710                                current_pass.cmds.push(Cmd::CompositeShadow {
10711                                    off,
10712                                    cnt: 1,
10713                                    layer_id: scratch_id,
10714                                });
10715                            }
10716                        }
10717                    }
10718                    flush_pending_layer!();
10719                }
10720                SceneNode::VectorMesh {
10721                    mesh,
10722                    transform,
10723                    paint,
10724                    clip: _,
10725                    blend,
10726                } => {
10727                    let affine = combine_mesh_affine(current_transform, *transform);
10728                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10729                    if !visible(mesh_aabb(mesh, affine), clip) {
10730                        continue;
10731                    }
10732                    flush_batch!();
10733                    let paint_alpha = match paint {
10734                        repose_core::PaintDesc::Linear {
10735                            start_color,
10736                            end_color,
10737                            ..
10738                        }
10739                        | repose_core::PaintDesc::Radial {
10740                            start_color,
10741                            end_color,
10742                            ..
10743                        }
10744                        | repose_core::PaintDesc::Sweep {
10745                            start_color,
10746                            end_color,
10747                            ..
10748                        } => Some(start_color.3.min(end_color.3) as f32 / 255.0),
10749                        repose_core::PaintDesc::LinearStops { stops, .. }
10750                        | repose_core::PaintDesc::RadialStops { stops, .. } => {
10751                            Some(stops.min_alpha())
10752                        }
10753                        _ => None,
10754                    };
10755                    let translucent_fixed_blend =
10756                        matches!(
10757                            blend,
10758                            repose_core::BlendMode::Add
10759                                | repose_core::BlendMode::Multiply
10760                                | repose_core::BlendMode::Screen
10761                                | repose_core::BlendMode::Darken
10762                                | repose_core::BlendMode::Lighten
10763                        ) && (mesh.vertices.iter().any(|vertex| vertex.color[3] < 0.9999)
10764                            || paint_alpha.is_some_and(|alpha| alpha < 0.9999));
10765                    if blend.needs_isolation() || translucent_fixed_blend {
10766                        let blend_scissor = to_scissor(
10767                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
10768                            current_target_size.0 as u32,
10769                            current_target_size.1 as u32,
10770                        );
10771                        self.emit_isolated_blend(
10772                            mesh.clone(),
10773                            *transform,
10774                            *paint,
10775                            *blend,
10776                            current_transform,
10777                            &mut current_pass,
10778                            &active_clips,
10779                            &mut passes,
10780                            &mut flatten_id_head,
10781                            &mut next_pass_id,
10782                            &mut flatten_ids_used,
10783                            blend_scissor,
10784                            encoder,
10785                            fb_w,
10786                            fb_h,
10787                        );
10788                    } else {
10789                        let t_identity = Transform::identity();
10790                        let current_transform = transform_stack.last().unwrap_or(&t_identity);
10791                        self.emit_vector_mesh(
10792                            current_transform,
10793                            mesh,
10794                            *transform,
10795                            paint,
10796                            *blend,
10797                            &mut current_pass.cmds,
10798                        );
10799                    }
10800                }
10801                SceneNode::VectorOverlay { meshes } => {
10802                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10803                    let has_visible = meshes
10804                        .iter()
10805                        .any(|mesh| visible(mesh_aabb(mesh, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip));
10806                    if !has_visible {
10807                        continue;
10808                    }
10809                    flush_batch!();
10810                    for (index, m) in meshes.iter().enumerate() {
10811                        if !visible(mesh_aabb(m, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip) {
10812                            continue;
10813                        }
10814                        let Some(mesh_gpu) = self.upload_overlay_mesh(meshes, index) else {
10815                            continue;
10816                        };
10817                        let Some(uoff) = self.alloc_mesh_uniform(MeshUniform::identity()) else {
10818                            continue;
10819                        };
10820                        current_pass.cmds.push(Cmd::VectorOverlay {
10821                            mesh: mesh_gpu,
10822                            uoff,
10823                        });
10824                    }
10825                }
10826                SceneNode::PushVectorClip { mesh, op } => {
10827                    flush_batch!();
10828                    let difference = matches!(op, repose_core::ClipOp::Difference);
10829                    let t_identity = Transform::identity();
10830                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10831                    let affine =
10832                        combine_mesh_affine(current_transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
10833                    let aabb = mesh_aabb(mesh, affine);
10834                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10835                    let next = if difference {
10836                        top
10837                    } else {
10838                        intersect(top, aabb)
10839                    };
10840                    scissor_stack.push(next);
10841                    let scissor = to_scissor(
10842                        &next,
10843                        current_target_size.0 as u32,
10844                        current_target_size.1 as u32,
10845                    );
10846                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10847                    let mut blocked = current_pass.active_scissor.is_none() || has_inverse;
10848                    if has_inverse {
10849                        log::error!(
10850                            "unsupported clip nesting: a Difference clip cannot contain another vector clip"
10851                        );
10852                    }
10853                    let has_area = aabb.x.is_finite()
10854                        && aabb.y.is_finite()
10855                        && aabb.w.is_finite()
10856                        && aabb.h.is_finite()
10857                        && aabb.w > 0.0
10858                        && aabb.h > 0.0;
10859                    let mut applied = false;
10860                    let mut geometry = None;
10861                    let mut uoff = 0;
10862                    if !blocked
10863                        && has_area
10864                        && (if difference {
10865                            visible(aabb, top)
10866                        } else {
10867                            scissor.2 > 0 && scissor.3 > 0
10868                        })
10869                    {
10870                        if let Some(mesh_gpu) = self.upload_mesh_geometry(mesh)
10871                            && let Some(slot) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
10872                                affine,
10873                                &repose_core::PaintDesc::Solid,
10874                            ))
10875                        {
10876                            geometry = Some(mesh_gpu.clone());
10877                            uoff = slot;
10878                            applied = true;
10879                            current_pass.cmds.push(Cmd::VectorClipPush {
10880                                mesh: mesh_gpu,
10881                                uoff,
10882                                scissor,
10883                                difference,
10884                                applied,
10885                            });
10886                        } else {
10887                            blocked = !difference;
10888                        }
10889                    } else if !difference {
10890                        blocked = true;
10891                    }
10892                    current_pass.active_scissor = if blocked {
10893                        None
10894                    } else {
10895                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10896                    };
10897                    active_clips.push(ActiveClip::Vector {
10898                        mesh: geometry,
10899                        source: mesh.clone(),
10900                        uoff,
10901                        affine,
10902                        scissor,
10903                        difference,
10904                        applied,
10905                        blocked,
10906                    });
10907                }
10908                SceneNode::PopVectorClip => {
10909                    flush_batch!();
10910                    if scissor_stack.is_empty() || active_clips.is_empty() {
10911                        log::error!("PopVectorClip does not match an active clip");
10912                    } else {
10913                        scissor_stack.pop();
10914                    }
10915                    let clip = active_clips.pop();
10916                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10917                    let scissor = to_scissor(
10918                        &top,
10919                        current_target_size.0 as u32,
10920                        current_target_size.1 as u32,
10921                    );
10922                    current_pass.active_scissor =
10923                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10924                    match clip {
10925                        Some(ActiveClip::Vector {
10926                            mesh: Some(mesh),
10927                            uoff,
10928                            scissor: stencil_scissor,
10929                            difference,
10930                            applied,
10931                            ..
10932                        }) if applied && mesh.vertex_count > 0 && mesh.index_count > 0 => {
10933                            current_pass.cmds.push(Cmd::VectorClipPop {
10934                                mesh,
10935                                uoff,
10936                                scissor,
10937                                stencil_scissor,
10938                                difference,
10939                                applied: true,
10940                            });
10941                        }
10942                        Some(ActiveClip::Rect { .. }) => {
10943                            log::error!("PopVectorClip matched a rounded-rect clip");
10944                        }
10945                        None => {}
10946                        _ => {}
10947                    }
10948                }
10949                SceneNode::Callback { rect, payload } => {
10950                    let t = transform_stack
10951                        .last()
10952                        .copied()
10953                        .unwrap_or(Transform::identity());
10954                    let transformed = affine_aabb(&t, rect);
10955                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10956                    let clip_rect = intersect(transformed, top);
10957                    let scissor = to_scissor(
10958                        &clip_rect,
10959                        current_target_size.0 as u32,
10960                        current_target_size.1 as u32,
10961                    );
10962                    let restore_scissor = to_scissor(
10963                        &top,
10964                        current_target_size.0 as u32,
10965                        current_target_size.1 as u32,
10966                    );
10967                    if current_pass.active_scissor.is_none()
10968                        || !visible(transformed, top)
10969                        || scissor.2 == 0
10970                        || scissor.3 == 0
10971                    {
10972                        continue;
10973                    }
10974                    flush_batch!();
10975                    current_pass.cmds.push(Cmd::Callback {
10976                        rect: transformed,
10977                        clip_rect,
10978                        scissor,
10979                        restore_scissor,
10980                        callback_id: Arc::as_ptr(payload) as *const () as usize,
10981                        payload: payload.clone(),
10982                    });
10983                }
10984                _ => {}
10985            }
10986        }
10987
10988        flush_pending_layer!();
10989        flush_batch!();
10990        passes.push(current_pass);
10991
10992        {
10993            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
10994            let mut prepare_list: Vec<(usize, Arc<Callback>)> = Vec::new();
10995            for pass in &passes {
10996                for command in &pass.cmds {
10997                    if let Cmd::Callback { payload, .. } = command
10998                        && payload.downcast_ref::<Callback>().is_some()
10999                    {
11000                        let ptr = Arc::as_ptr(payload) as *const () as usize;
11001                        if seen.insert(ptr)
11002                            && let Ok(cb_arc) = payload.clone().downcast::<Callback>()
11003                        {
11004                            prepare_list.push((ptr, cb_arc));
11005                        }
11006                    }
11007                }
11008            }
11009            if !prepare_list.is_empty() {
11010                let mut callback_targets: HashMap<usize, Vec<(PassTarget, ScreenDescriptor)>> =
11011                    HashMap::new();
11012                for pass in &passes {
11013                    let (size, target_format, sample_count) = match pass.target {
11014                        PassTarget::Surface => (
11015                            [self.output_width, self.output_height],
11016                            if self.working_space {
11017                                wgpu::TextureFormat::Rgba16Float
11018                            } else {
11019                                self.output_format
11020                            },
11021                            self.active_surface_msaa_samples(),
11022                        ),
11023                        PassTarget::Layer(layer_id) => {
11024                            let layer = self.layer_pool.get(&layer_id);
11025                            (
11026                                layer.map_or([self.output_width, self.output_height], |layer| {
11027                                    [layer.width, layer.height]
11028                                }),
11029                                self.layer_target_format(),
11030                                1,
11031                            )
11032                        }
11033                    };
11034                    for command in &pass.cmds {
11035                        if let Cmd::Callback { payload, .. } = command {
11036                            let descriptor = ScreenDescriptor {
11037                                size_in_pixels: size,
11038                                pixels_per_point: self.pixels_per_point,
11039                                target_format,
11040                                sample_count,
11041                            };
11042                            let targets = callback_targets
11043                                .entry(Arc::as_ptr(payload) as *const () as usize)
11044                                .or_default();
11045                            if !targets.iter().any(|(target, target_desc)| {
11046                                *target == pass.target
11047                                    && target_desc.target_format == descriptor.target_format
11048                                    && target_desc.sample_count == descriptor.sample_count
11049                            }) {
11050                                targets.push((pass.target, descriptor));
11051                            }
11052                        }
11053                    }
11054                }
11055
11056                let default_descriptor = ScreenDescriptor {
11057                    size_in_pixels: [self.output_width, self.output_height],
11058                    pixels_per_point: self.pixels_per_point,
11059                    target_format: if self.working_space {
11060                        wgpu::TextureFormat::Rgba16Float
11061                    } else {
11062                        self.output_format
11063                    },
11064                    sample_count: self.active_surface_msaa_samples(),
11065                };
11066                let default_descriptors = [(PassTarget::Surface, default_descriptor)];
11067                let mut prepare_encoder =
11068                    self.device
11069                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11070                            label: Some("callback prepare"),
11071                        });
11072                let mut finish_encoder =
11073                    self.device
11074                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11075                            label: Some("callback finish prepare"),
11076                        });
11077                let mut active_callback_scopes = HashSet::new();
11078                for (key, cb) in &prepare_list {
11079                    let descriptors = callback_targets
11080                        .get(key)
11081                        .map(Vec::as_slice)
11082                        .unwrap_or(&default_descriptors);
11083                    for &(target, screen_desc) in descriptors {
11084                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11085                        active_callback_scopes.insert(scope.clone());
11086                        if !scope.callback.reusable
11087                            && self
11088                                .callback_scope_payloads
11089                                .get(&scope)
11090                                .is_some_and(|payload| payload.upgrade().is_none())
11091                        {
11092                            self.remove_callback_scope(&scope);
11093                        }
11094                        let payload: Weak<Callback> = Arc::downgrade(cb);
11095                        self.callback_scope_payloads.insert(scope, payload);
11096                    }
11097                }
11098                let mut prepare_buffers = Vec::new();
11099                let mut finish_buffers = Vec::new();
11100                for (key, cb) in &prepare_list {
11101                    let descriptors = callback_targets
11102                        .get(key)
11103                        .map(Vec::as_slice)
11104                        .unwrap_or(&default_descriptors);
11105                    for &(target, screen_desc) in descriptors {
11106                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11107                        self.touch_callback_scope(scope.clone());
11108                        if !self.callback_scoped_resources.contains_key(&scope)
11109                            && self.callback_scoped_resources.len() >= MAX_CALLBACK_SCOPES
11110                        {
11111                            self.evict_callback_scope(&active_callback_scopes);
11112                        }
11113                        let resources = self.callback_scoped_resources.entry(scope).or_default();
11114                        prepare_buffers.extend(cb.0.prepare(
11115                            &self.device,
11116                            &self.queue,
11117                            &mut prepare_encoder,
11118                            &screen_desc,
11119                            resources,
11120                        ));
11121                    }
11122                }
11123                for (key, cb) in &prepare_list {
11124                    let descriptors = callback_targets
11125                        .get(key)
11126                        .map(Vec::as_slice)
11127                        .unwrap_or(&default_descriptors);
11128                    for &(target, screen_desc) in descriptors {
11129                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11130                        self.touch_callback_scope(scope.clone());
11131                        if let Some(resources) = self.callback_scoped_resources.get_mut(&scope) {
11132                            finish_buffers.extend(cb.0.finish_prepare(
11133                                &self.device,
11134                                &self.queue,
11135                                &mut finish_encoder,
11136                                &screen_desc,
11137                                resources,
11138                            ));
11139                        }
11140                    }
11141                }
11142                let mut callback_buffers =
11143                    Vec::with_capacity(2 + prepare_buffers.len() + finish_buffers.len());
11144                callback_buffers.push(prepare_encoder.finish());
11145                callback_buffers.extend(prepare_buffers);
11146                callback_buffers.push(finish_encoder.finish());
11147                callback_buffers.extend(finish_buffers);
11148                self.queue.submit(callback_buffers);
11149            }
11150        }
11151
11152        let globals_bytes = std::mem::size_of::<Globals>() as u64;
11153        if !self.ensure_globals_staging(passes.len()) {
11154            return;
11155        }
11156        for (i, pass) in passes.iter().enumerate() {
11157            let (target_w, target_h) = match pass.target {
11158                PassTarget::Surface => (fb_w, fb_h),
11159                PassTarget::Layer(layer_id) => {
11160                    let lt = self.layer_pool.get(&layer_id);
11161                    (
11162                        lt.map_or(fb_w, |l| l.width as f32),
11163                        lt.map_or(fb_h, |l| l.height as f32),
11164                    )
11165                }
11166            };
11167            self.queue.write_buffer(
11168                &self.globals_staging,
11169                (i as u64) * globals_bytes,
11170                bytemuck::bytes_of(&make_globals(target_w, target_h)),
11171            );
11172        }
11173
11174        let bind_mask = self.atlas_bind_group_mask();
11175        let bind_color = self.atlas_bind_group_color();
11176        let mut clip_depth: u32 = 0;
11177        let mut clip_depth_stack: Vec<u32> = Vec::new();
11178
11179        let snapshot_source = target_texture.cloned();
11180        let last_surface_pass = passes
11181            .iter()
11182            .rposition(|pass| matches!(pass.target, PassTarget::Surface));
11183        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
11184            // Populate backdrop snapshots for blends composited in this
11185            // pass: copy the parent target region into the snapshot
11186            // texture. Passes execute in order, so the parent holds the
11187            // true backdrop. Surface parents need the target texture (no
11188            // swapchain sampling mid-frame); layer parents copy from the
11189            // pool. Either way the region is parent-target pixels. Copies
11190            // for other passes stay queued (`remaining`).
11191            let needs_snapshot = pass
11192                .cmds
11193                .iter()
11194                .any(|c| matches!(c, Cmd::BlendLayer { .. }));
11195            if needs_snapshot {
11196                let copies = std::mem::take(&mut self.blend_copies);
11197                let has_surface_copy = copies.iter().any(|copy| copy.target == PassTarget::Surface);
11198                let mut resolved_surface = None;
11199                if has_surface_copy
11200                    && !self.working_space
11201                    && let (Some(msaa_view), Some(resolve_view)) =
11202                        (self.msaa_view.as_ref(), self.surface_resolve_view.as_ref())
11203                {
11204                    let resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11205                        label: Some("surface blend resolve"),
11206                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11207                            view: msaa_view,
11208                            resolve_target: Some(resolve_view),
11209                            ops: wgpu::Operations {
11210                                load: wgpu::LoadOp::Load,
11211                                store: wgpu::StoreOp::Store,
11212                            },
11213                            depth_slice: None,
11214                        })],
11215                        depth_stencil_attachment: None,
11216                        timestamp_writes: None,
11217                        occlusion_query_set: None,
11218                        multiview_mask: None,
11219                    });
11220                    drop(resolve_pass);
11221                    resolved_surface = self.surface_resolve_tex.clone();
11222                }
11223                let mut remaining = Vec::with_capacity(copies.len());
11224                for copy in copies {
11225                    if copy.pass_id != pass.id {
11226                        remaining.push(copy);
11227                        continue;
11228                    }
11229                    let BlendCopy {
11230                        blend_id,
11231                        target,
11232                        region,
11233                        ..
11234                    } = copy;
11235                    let (src_tex, tw, th) = match target {
11236                        PassTarget::Layer(parent_id) => match self.layer_pool.get(&parent_id) {
11237                            Some(lt) => (lt.texture.clone(), lt.width, lt.height),
11238                            None => continue,
11239                        },
11240                        PassTarget::Surface => {
11241                            let texture = if self.working_space {
11242                                self.ws_tex.clone()
11243                            } else if self.msaa_view.is_some() {
11244                                resolved_surface.clone()
11245                            } else {
11246                                snapshot_source.clone()
11247                            };
11248                            match texture {
11249                                Some(texture) => (texture, self.output_width, self.output_height),
11250                                None => {
11251                                    self.remove_blend_snapshot(blend_id);
11252                                    continue;
11253                                }
11254                            }
11255                        }
11256                    };
11257                    let Some(dst_tex) = self.blend_snapshot_texture(blend_id) else {
11258                        continue;
11259                    };
11260                    if !src_tex.usage().contains(wgpu::TextureUsages::COPY_SRC)
11261                        || !dst_tex.usage().contains(wgpu::TextureUsages::COPY_DST)
11262                    {
11263                        self.remove_blend_snapshot(blend_id);
11264                        continue;
11265                    }
11266                    let Some((sx, sy, cw, ch)) = checked_copy_region(region, tw, th) else {
11267                        self.remove_blend_snapshot(blend_id);
11268                        continue;
11269                    };
11270                    encoder.copy_texture_to_texture(
11271                        wgpu::TexelCopyTextureInfo {
11272                            texture: &src_tex,
11273                            mip_level: 0,
11274                            origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
11275                            aspect: wgpu::TextureAspect::All,
11276                        },
11277                        wgpu::TexelCopyTextureInfo {
11278                            texture: &dst_tex,
11279                            mip_level: 0,
11280                            origin: wgpu::Origin3d::ZERO,
11281                            aspect: wgpu::TextureAspect::All,
11282                        },
11283                        wgpu::Extent3d {
11284                            width: cw,
11285                            height: ch,
11286                            depth_or_array_layers: 1,
11287                        },
11288                    );
11289                }
11290                self.blend_copies = remaining;
11291            }
11292            let (color_view, resolve_target, depth_stencil_view, is_layer, working_target) =
11293                match pass.target {
11294                    PassTarget::Surface => {
11295                        let swap_view = target_view.clone();
11296                        let use_ws = self.working_space && self.ws_view.is_some();
11297                        let (color, resolve) = if use_ws {
11298                            let ws_view = self.ws_view.as_ref().unwrap();
11299                            if let Some(msaa_view) = &self.ws_msaa_view {
11300                                (msaa_view.clone(), Some(ws_view.clone()))
11301                            } else {
11302                                (ws_view.clone(), None)
11303                            }
11304                        } else if let Some(msaa_view) = &self.msaa_view {
11305                            // Resolve once per frame. Graphics layers split the
11306                            // surface into several passes that all accumulate into
11307                            // the same multisampled attachment, so only the last
11308                            // one can contribute to the presented image.
11309                            let resolve =
11310                                (Some(pass_index) == last_surface_pass).then(|| swap_view.clone());
11311                            (msaa_view.clone(), resolve)
11312                        } else {
11313                            (swap_view, None)
11314                        };
11315                        (
11316                            color,
11317                            resolve,
11318                            self.depth_stencil_view.clone(),
11319                            false,
11320                            use_ws,
11321                        )
11322                    }
11323                    PassTarget::Layer(layer_id) => {
11324                        if let Some(lt) = self.layer_pool.get(&layer_id) {
11325                            (
11326                                lt.view.clone(),
11327                                None,
11328                                lt.depth_stencil_view.clone(),
11329                                true,
11330                                false,
11331                            )
11332                        } else {
11333                            log::warn!("missing layer target {layer_id}");
11334                            continue;
11335                        }
11336                    }
11337                };
11338
11339            encoder.copy_buffer_to_buffer(
11340                &self.globals_staging,
11341                (pass_index as u64) * globals_bytes,
11342                &self.globals_buf,
11343                0,
11344                globals_bytes,
11345            );
11346
11347            if is_layer {
11348                clip_depth_stack.push(clip_depth);
11349                clip_depth = 0;
11350            }
11351
11352            let (tw, th) = match pass.target {
11353                PassTarget::Surface => (self.output_width, self.output_height),
11354                PassTarget::Layer(layer_id) => self
11355                    .layer_pool
11356                    .get(&layer_id)
11357                    .map(|l| (l.width, l.height))
11358                    .unwrap_or((self.output_width, self.output_height)),
11359            };
11360            let initial_scissor = clamp_scissor(
11361                pass.initial_scissor.0,
11362                pass.initial_scissor.1,
11363                pass.initial_scissor.2,
11364                pass.initial_scissor.3,
11365                tw,
11366                th,
11367            );
11368            let mut active_scissor = pass
11369                .active_scissor
11370                .map(|scissor| clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th));
11371            if active_scissor.is_some_and(|scissor| scissor.2 == 0 || scissor.3 == 0) {
11372                active_scissor = None;
11373            }
11374
11375            let pipes: &Pipelines = if is_layer {
11376                if self.working_space {
11377                    &self.working_space_layer_pipes
11378                } else {
11379                    &self.layer_pipes
11380                }
11381            } else if working_target {
11382                &self.working_space_pipes
11383            } else {
11384                &self.surface_pipes
11385            };
11386
11387            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11388                label: Some("pass"),
11389                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11390                    view: &color_view,
11391                    resolve_target: resolve_target.as_ref(),
11392                    ops: wgpu::Operations {
11393                        load: match pass.clear_color {
11394                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
11395                                r: c[0] as f64,
11396                                g: c[1] as f64,
11397                                b: c[2] as f64,
11398                                a: c[3] as f64,
11399                            }),
11400                            None => wgpu::LoadOp::Load,
11401                        },
11402                        store: wgpu::StoreOp::Store,
11403                    },
11404                    depth_slice: None,
11405                })],
11406                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
11407                    view: &depth_stencil_view,
11408                    depth_ops: None,
11409                    stencil_ops: Some(wgpu::Operations {
11410                        load: if pass.clear_color.is_some() {
11411                            wgpu::LoadOp::Clear(STENCIL_BASE)
11412                        } else {
11413                            wgpu::LoadOp::Load
11414                        },
11415                        store: wgpu::StoreOp::Store,
11416                    }),
11417                }),
11418                timestamp_writes: None,
11419                occlusion_query_set: None,
11420                multiview_mask: None,
11421            });
11422
11423            rpass.set_bind_group(0, &self.globals_bind, &[]);
11424            rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11425            let initial_draw_scissor = if initial_scissor.2 == 0 || initial_scissor.3 == 0 {
11426                (0, 0, 1, 1)
11427            } else {
11428                initial_scissor
11429            };
11430            rpass.set_scissor_rect(
11431                initial_draw_scissor.0,
11432                initial_draw_scissor.1,
11433                initial_draw_scissor.2,
11434                initial_draw_scissor.3,
11435            );
11436
11437            macro_rules! draw_simple {
11438                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
11439                    rpass.set_pipeline($pipeline);
11440                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11441                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11442                    rpass.draw(0..6, 0..$n);
11443                }};
11444            }
11445
11446            macro_rules! draw_with_bind {
11447                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
11448                    rpass.set_pipeline($pipeline);
11449                    rpass.set_bind_group(1, $bind, &[]);
11450                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11451                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11452                    rpass.draw(0..6, 0..$n);
11453                }};
11454            }
11455
11456            macro_rules! draw_indexed_mesh {
11457                ($pipeline:expr, $uoff:ident, $mesh:ident) => {{
11458                    rpass.set_pipeline($pipeline);
11459                    rpass.set_bind_group(1, &self.mesh_bind, &[$uoff as u32]);
11460                    let vbytes =
11461                        ($mesh.vertex_count as u64) * std::mem::size_of::<MeshVertex>() as u64;
11462                    rpass.set_vertex_buffer(0, $mesh.vertices.slice(0..vbytes));
11463                    let ibytes = ($mesh.index_count as u64) * std::mem::size_of::<u32>() as u64;
11464                    rpass.set_index_buffer(
11465                        $mesh.indices.slice(0..ibytes),
11466                        wgpu::IndexFormat::Uint32,
11467                    );
11468                    rpass.draw_indexed(0..$mesh.index_count, 0, 0..1);
11469                }};
11470            }
11471
11472            for cmd in pass.cmds {
11473                if active_scissor.is_none()
11474                    && !matches!(
11475                        &cmd,
11476                        Cmd::ClipPush { .. }
11477                            | Cmd::ClipPop { .. }
11478                            | Cmd::VectorClipPush { .. }
11479                            | Cmd::VectorClipPop { .. }
11480                    )
11481                {
11482                    continue;
11483                }
11484                match cmd {
11485                    Cmd::ClipPush {
11486                        off,
11487                        cnt: n,
11488                        scissor,
11489                        difference,
11490                        applied,
11491                    } => {
11492                        let scissor =
11493                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11494                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11495                        if usable {
11496                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11497                            active_scissor = Some(scissor);
11498                            rpass.set_pipeline(if difference {
11499                                &pipes.clip_dec
11500                            } else {
11501                                &pipes.clip_bin
11502                            });
11503
11504                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11505                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11506                            rpass.draw(0..6, 0..n);
11507                            if !difference {
11508                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11509                            }
11510                        } else {
11511                            active_scissor = None;
11512                        }
11513                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11514                    }
11515
11516                    Cmd::ClipPop {
11517                        off,
11518                        cnt: n,
11519                        scissor,
11520                        stencil_scissor,
11521                        difference,
11522                        applied,
11523                    } => {
11524                        let scissor =
11525                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11526                        let dec = clamp_scissor(
11527                            stencil_scissor.0,
11528                            stencil_scissor.1,
11529                            stencil_scissor.2,
11530                            stencil_scissor.3,
11531                            tw,
11532                            th,
11533                        );
11534                        if scissor.2 > 0 && scissor.3 > 0 {
11535                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11536                            active_scissor = Some(scissor);
11537                        } else {
11538                            active_scissor = None;
11539                        }
11540                        if applied && n > 0 && dec.2 > 0 && dec.3 > 0 {
11541                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11542                            rpass.set_pipeline(if difference {
11543                                &pipes.clip_bin
11544                            } else {
11545                                &pipes.clip_dec
11546                            });
11547                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11548                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11549                            rpass.draw(0..6, 0..n);
11550                            if scissor.2 > 0 && scissor.3 > 0 {
11551                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11552                            }
11553                            if !difference {
11554                                clip_depth = clip_depth.saturating_sub(1);
11555                            }
11556                        }
11557                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11558                    }
11559
11560                    Cmd::Rect { off, cnt: n } => {
11561                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
11562                    }
11563
11564                    Cmd::Border { off, cnt: n } => {
11565                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
11566                    }
11567
11568                    Cmd::GlyphsMask { off, cnt: n } => {
11569                        draw_with_bind!(
11570                            &pipes.text_mask,
11571                            self.glyph_mask.ring,
11572                            GlyphInstance,
11573                            &bind_mask,
11574                            off,
11575                            n
11576                        );
11577                    }
11578
11579                    Cmd::GlyphsColor { off, cnt: n } => {
11580                        draw_with_bind!(
11581                            &pipes.text_color,
11582                            self.glyph_color.ring,
11583                            GlyphInstance,
11584                            &bind_color,
11585                            off,
11586                            n
11587                        );
11588                    }
11589
11590                    Cmd::GlyphsVector { off, cnt: n } => {
11591                        if let Some(slug_pipe) = pipes.slug.as_ref() {
11592                            rpass.set_pipeline(slug_pipe);
11593                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
11594                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
11595                            rpass.draw(0..n, 0..1);
11596                        }
11597                    }
11598
11599                    Cmd::ImageRgba {
11600                        off,
11601                        cnt: n,
11602                        handle,
11603                        filter,
11604                        tile,
11605                        blend,
11606                    } => {
11607                        let bind_opt = match self.images.get(&handle) {
11608                            Some(ImageTex::Rgba { binds, .. }) => Some(binds.get(filter, tile)),
11609                            Some(ImageTex::User { binds, .. }) => Some(binds.get(filter, tile)),
11610                            _ => None,
11611                        };
11612                        if let Some(bind) = bind_opt {
11613                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11614                            {
11615                                Some(index) => &pipes.image_blends[index],
11616                                None => &pipes.image_rgba,
11617                            };
11618                            draw_with_bind!(
11619                                pipe,
11620                                self.glyph_color.ring,
11621                                GlyphInstance,
11622                                bind,
11623                                off,
11624                                n
11625                            );
11626                        }
11627                    }
11628                    Cmd::Coverage {
11629                        off,
11630                        cnt: n,
11631                        handle,
11632                    } => {
11633                        if let Some(tile) = self.coverages.get(&handle) {
11634                            draw_with_bind!(
11635                                &pipes.coverage,
11636                                self.glyph_color.ring,
11637                                GlyphInstance,
11638                                &tile.bind,
11639                                off,
11640                                n
11641                            );
11642                        }
11643                    }
11644
11645                    Cmd::ImageNv12 {
11646                        off,
11647                        cnt: n,
11648                        handle,
11649                        filter,
11650                        blend,
11651                    } => {
11652                        if let Some(ImageTex::Nv12 { binds, .. }) = self.images.get(&handle) {
11653                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11654                            {
11655                                Some(index) => &pipes.image_nv12_blends[index],
11656                                None => &pipes.image_nv12,
11657                            };
11658                            draw_with_bind!(
11659                                pipe,
11660                                self.nv12.ring,
11661                                Nv12Instance,
11662                                binds.get(filter, false),
11663                                off,
11664                                n
11665                            );
11666                        }
11667                    }
11668
11669                    Cmd::Ellipse { off, cnt: n } => {
11670                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
11671                    }
11672
11673                    Cmd::EllipseBorder { off, cnt: n } => {
11674                        draw_simple!(
11675                            &pipes.ellipse_borders,
11676                            self.ellipse_borders.ring,
11677                            EllipseBorderInstance,
11678                            off,
11679                            n
11680                        );
11681                    }
11682
11683                    Cmd::Arc { off, cnt: n } => {
11684                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
11685                    }
11686
11687                    Cmd::CompositeLayer {
11688                        off,
11689                        cnt: n,
11690                        layer_id,
11691                    } => {
11692                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11693                            draw_with_bind!(
11694                                &pipes.image_rgba,
11695                                self.glyph_color.ring,
11696                                GlyphInstance,
11697                                &lt.bind,
11698                                off,
11699                                n
11700                            );
11701                        }
11702                    }
11703                    Cmd::CompositeShadow {
11704                        off,
11705                        cnt: n,
11706                        layer_id,
11707                    } => {
11708                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11709                            draw_with_bind!(
11710                                &pipes.blur,
11711                                self.blur_ring,
11712                                BlurInstance,
11713                                &lt.bind_linear,
11714                                off,
11715                                n
11716                            );
11717                        }
11718                    }
11719                    Cmd::CompositeBlur {
11720                        off,
11721                        cnt: n,
11722                        layer_id,
11723                    } => {
11724                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11725                            draw_with_bind!(
11726                                &pipes.blur_content,
11727                                self.blur_ring,
11728                                BlurInstance,
11729                                &lt.bind_linear,
11730                                off,
11731                                n
11732                            );
11733                        }
11734                    }
11735                    Cmd::CompositeProjective {
11736                        off,
11737                        cnt: n,
11738                        layer_id,
11739                    } => {
11740                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11741                            // The layer texture is sampled with the rgba
11742                            // (non-linear-filter) binding, like the sharp
11743                            // composite path.
11744                            draw_with_bind!(
11745                                &pipes.projective_layer,
11746                                self.projective_ring,
11747                                ProjectiveInstance,
11748                                &lt.bind,
11749                                off,
11750                                n
11751                            );
11752                        }
11753                    }
11754
11755                    Cmd::BlendLayer {
11756                        off,
11757                        cnt: n,
11758                        src_layer,
11759                        dst_layer,
11760                        parent,
11761                        scissor,
11762                    } => {
11763                        let src = self.layer_pool.get(&src_layer).cloned();
11764                        // Backdrop is the snapshot copied from the parent
11765                        // target before this pass opened (a texture cannot be
11766                        // sampled mid-pass while bound as a render target).
11767                        // A missing snapshot (surface parent without texture,
11768                        // evicted layer) skips the draw: the mesh vanishes
11769                        // rather than misrendering.
11770                        let dst = dst_layer.and_then(|id| self.blend_snapshots.get(&id).cloned());
11771                        let in_parent = match parent {
11772                            PassTarget::Surface => !is_layer,
11773                            PassTarget::Layer(id) => {
11774                                matches!(pass.target, PassTarget::Layer(pid) if pid == id)
11775                            }
11776                        };
11777                        if let (Some(src_lt), Some(dst_snap)) = (src, dst)
11778                            && in_parent
11779                        {
11780                            let bytes = (n as u64) * std::mem::size_of::<BlendInstance>() as u64;
11781                            rpass.set_pipeline(&pipes.blend_layer);
11782                            let scissor =
11783                                clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11784                            if scissor.2 > 0 && scissor.3 > 0 {
11785                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11786                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11787                                rpass.set_bind_group(1, &src_lt.bind, &[]);
11788                                rpass.set_bind_group(2, &dst_snap.bind, &[]);
11789                                rpass.set_vertex_buffer(
11790                                    0,
11791                                    self.blend_ring.buf.slice(off..off + bytes),
11792                                );
11793                                rpass.draw(0..6, 0..n);
11794                            }
11795                        }
11796                    }
11797
11798                    Cmd::VectorMesh { mesh, uoff, blend } => {
11799                        let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend) {
11800                            Some(index) => &pipes.mesh_blends[index],
11801                            None => &pipes.mesh,
11802                        };
11803                        draw_indexed_mesh!(pipe, uoff, mesh);
11804                    }
11805
11806                    Cmd::VectorOverlay { mesh, uoff } => {
11807                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11808                        draw_indexed_mesh!(&pipes.mesh_overlay, uoff, mesh);
11809                    }
11810
11811                    Cmd::VectorClipPush {
11812                        mesh,
11813                        uoff,
11814                        scissor,
11815                        difference,
11816                        applied,
11817                    } => {
11818                        let scissor =
11819                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11820                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11821                        if usable {
11822                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11823                            active_scissor = Some(scissor);
11824                            let pipe = if difference {
11825                                &pipes.mesh_clip_dec
11826                            } else {
11827                                &pipes.mesh_clip_inc
11828                            };
11829                            draw_indexed_mesh!(pipe, uoff, mesh);
11830                            if !difference {
11831                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11832                            }
11833                        } else {
11834                            active_scissor = None;
11835                        }
11836                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11837                    }
11838
11839                    Cmd::VectorClipPop {
11840                        mesh,
11841                        uoff,
11842                        scissor,
11843                        stencil_scissor,
11844                        difference,
11845                        applied,
11846                    } => {
11847                        let scissor =
11848                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11849                        let dec = clamp_scissor(
11850                            stencil_scissor.0,
11851                            stencil_scissor.1,
11852                            stencil_scissor.2,
11853                            stencil_scissor.3,
11854                            tw,
11855                            th,
11856                        );
11857                        if scissor.2 > 0 && scissor.3 > 0 {
11858                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11859                            active_scissor = Some(scissor);
11860                        } else {
11861                            active_scissor = None;
11862                        }
11863                        if applied && dec.2 > 0 && dec.3 > 0 {
11864                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11865                            let pipe = if difference {
11866                                &pipes.mesh_clip_inc
11867                            } else {
11868                                &pipes.mesh_clip_dec
11869                            };
11870                            draw_indexed_mesh!(pipe, uoff, mesh);
11871                            if scissor.2 > 0 && scissor.3 > 0 {
11872                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11873                            }
11874                            if !difference {
11875                                clip_depth = clip_depth.saturating_sub(1);
11876                            }
11877                        }
11878                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11879                    }
11880
11881                    Cmd::Callback {
11882                        rect,
11883                        clip_rect,
11884                        scissor,
11885                        restore_scissor,
11886                        callback_id,
11887                        payload,
11888                    } => {
11889                        if let Some(cb) = payload.downcast_ref::<Callback>() {
11890                            // Round INWARD. A callback may only paint inside its
11891                            // own rect, and a fullscreen pass draws a single
11892                            // clip-space quad spanning twice the viewport, so an
11893                            // outward-rounded viewport overshot the rect and the
11894                            // outward-rounded scissor let that overshoot through.
11895                            let vp_x = rect.x.ceil().max(0.0);
11896                            let vp_y = rect.y.ceil().max(0.0);
11897                            let vp_w = ((rect.x + rect.w).floor() - vp_x).max(1.0);
11898                            let vp_h = ((rect.y + rect.h).floor() - vp_y).max(1.0);
11899                            if vp_w > 0.0 && vp_h > 0.0 && clip_rect.w > 0.0 && clip_rect.h > 0.0 {
11900                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11901                                rpass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
11902                                let info = repose_core::PaintCallbackInfo {
11903                                    viewport: rect,
11904                                    clip_rect,
11905                                    pixels_per_point: self.pixels_per_point,
11906                                    screen_size_px: [tw, th],
11907                                };
11908                                let descriptor = ScreenDescriptor {
11909                                    size_in_pixels: [tw, th],
11910                                    pixels_per_point: self.pixels_per_point,
11911                                    target_format: if is_layer {
11912                                        self.layer_target_format()
11913                                    } else if working_target {
11914                                        wgpu::TextureFormat::Rgba16Float
11915                                    } else {
11916                                        self.output_format
11917                                    },
11918                                    sample_count: if is_layer {
11919                                        1
11920                                    } else {
11921                                        self.active_surface_msaa_samples()
11922                                    },
11923                                };
11924                                let scope =
11925                                    callback_scope_key(cb, callback_id, pass.target, &descriptor);
11926                                let resources = self
11927                                    .callback_scoped_resources
11928                                    .get(&scope)
11929                                    .unwrap_or(&self.callback_resources);
11930                                // A panicking callback must not skip the restore below:
11931                                // the viewport/scissor would stay clamped to
11932                                // the callback rect and every later draw in the
11933                                // frame would be clipped away. Requires
11934                                // unwinding panics; under `panic = "abort"`
11935                                // nothing runs the restore.
11936                                let outcome = {
11937                                    let mut callback_pass = CallbackRenderPass::new(
11938                                        &mut rpass,
11939                                        descriptor.target_format,
11940                                        descriptor.sample_count,
11941                                    );
11942                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11943                                        cb.0.paint(info, &mut callback_pass, resources)
11944                                    }))
11945                                };
11946                                if outcome.is_err() {
11947                                    log::warn!("paint callback panicked; state restored");
11948                                }
11949                                rpass.set_viewport(0.0, 0.0, tw as f32, th as f32, 0.0, 1.0);
11950                                let restore = if restore_scissor.2 > 0 && restore_scissor.3 > 0 {
11951                                    restore_scissor
11952                                } else {
11953                                    (0, 0, 1, 1)
11954                                };
11955                                rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
11956                                rpass.set_bind_group(0, &self.globals_bind, &[]);
11957                                rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11958                            }
11959                        } else {
11960                            log::warn!("Unknown paint callback payload");
11961                        }
11962                    }
11963                }
11964            }
11965            if is_layer {
11966                clip_depth = clip_depth_stack.pop().unwrap_or(0);
11967            }
11968        }
11969
11970        let transient_ids: HashSet<u32> = flatten_ids_used.iter().copied().collect();
11971        let producer_ids = std::mem::take(&mut self.producer_layer_ids);
11972        // A blur scratch stays bound while its source layer is live, so a stable
11973        // layer reuses its allocation instead of reallocating each frame. Once
11974        // the source is swept the scratch id is forgotten and goes with it.
11975        self.blur_scratch_ids
11976            .retain(|&(source, _), _| producer_ids.contains(&source));
11977        let live_scratch: HashSet<u32> = self.blur_scratch_ids.values().copied().collect();
11978        let stale_layers: Vec<u32> = self
11979            .layer_pool
11980            .keys()
11981            .filter_map(|id| {
11982                (!producer_ids.contains(id)
11983                    && !transient_ids.contains(id)
11984                    && !live_scratch.contains(id))
11985                .then_some(*id)
11986            })
11987            .collect();
11988        for id in stale_layers {
11989            if let Some(layer) = self.layer_pool.remove(&id) {
11990                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
11991                if layer.transient {
11992                    self.transient_layer_bytes_total =
11993                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
11994                }
11995            }
11996        }
11997        self.flatten_layer_ids = flatten_ids_used;
11998
11999        // Display pass: linear working space -> sRGB OETF -> swapchain
12000        if self.working_space
12001            && let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
12002                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
12003        {
12004            let swap_view = target_view.clone();
12005            let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
12006                label: Some("display transform"),
12007                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
12008                    view: &swap_view,
12009                    resolve_target: None,
12010                    ops: wgpu::Operations {
12011                        load: wgpu::LoadOp::Load,
12012                        store: wgpu::StoreOp::Store,
12013                    },
12014                    depth_slice: None,
12015                })],
12016                depth_stencil_attachment: None,
12017                timestamp_writes: None,
12018                occlusion_query_set: None,
12019                multiview_mask: None,
12020            });
12021            display_pass.set_pipeline(display_pipeline);
12022            display_pass.set_bind_group(1, ws_bind, &[]);
12023            display_pass.draw(0..3, 0..1);
12024        }
12025        self.recycle_blend_snapshots();
12026    }
12027
12028    /// Render a scene into an externally-provided texture view.
12029    /// Use this when embedding Repose in a host that owns the GPU.
12030    /// The host is responsible for submitting the encoder and handling present.
12031    pub fn render_to_view(
12032        &mut self,
12033        scene: &Scene,
12034        encoder: &mut wgpu::CommandEncoder,
12035        target_view: &wgpu::TextureView,
12036        width: u32,
12037        height: u32,
12038        clear_color: Option<[f64; 4]>,
12039    ) {
12040        self.resize(width, height);
12041
12042        if let Err(error) = self.begin_frame() {
12043            log::error!("render_to_view: {error:#}");
12044            return;
12045        }
12046
12047        if self.output_width == 0 || self.output_height == 0 {
12048            self.end_frame();
12049            return;
12050        }
12051
12052        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
12053        self.end_frame();
12054    }
12055}
12056
12057#[cfg(target_os = "linux")]
12058fn validate_dmabuf_plane(
12059    fd: &std::os::unix::io::OwnedFd,
12060    offset: u64,
12061    stride: u32,
12062    width: u32,
12063    height: u32,
12064    bytes_per_pixel: u32,
12065) -> anyhow::Result<u64> {
12066    if width == 0 || height == 0 || bytes_per_pixel == 0 {
12067        anyhow::bail!("DMA-BUF plane dimensions and bytes per pixel must be non-zero");
12068    }
12069    if !offset.is_multiple_of(4) || !stride.is_multiple_of(4) {
12070        anyhow::bail!("DMA-BUF offset and stride must be 4-byte aligned");
12071    }
12072    let row_bytes = (width as u64)
12073        .checked_mul(bytes_per_pixel as u64)
12074        .ok_or_else(|| anyhow::anyhow!("DMA-BUF row size overflow"))?;
12075    if (stride as u64) < row_bytes {
12076        anyhow::bail!(
12077            "DMA-BUF stride {} is smaller than row size {row_bytes}",
12078            stride
12079        );
12080    }
12081    let span = (height as u64 - 1)
12082        .checked_mul(stride as u64)
12083        .and_then(|value| value.checked_add(row_bytes))
12084        .ok_or_else(|| anyhow::anyhow!("DMA-BUF plane size overflow"))?;
12085    let file = std::fs::File::from(
12086        fd.try_clone()
12087            .map_err(|error| anyhow::anyhow!("duplicate DMA-BUF fd: {error}"))?,
12088    );
12089    let file_size = file
12090        .metadata()
12091        .map_err(|error| anyhow::anyhow!("stat DMA-BUF fd: {error}"))?
12092        .len();
12093    if offset > file_size || span > file_size - offset {
12094        anyhow::bail!("DMA-BUF plane offset/size {offset}+{span} exceeds file size {file_size}");
12095    }
12096    Ok(file_size)
12097}
12098
12099fn is_filterable_color_format(format: wgpu::TextureFormat) -> bool {
12100    matches!(
12101        format,
12102        wgpu::TextureFormat::Rgba8Unorm
12103            | wgpu::TextureFormat::Rgba8UnormSrgb
12104            | wgpu::TextureFormat::Bgra8Unorm
12105            | wgpu::TextureFormat::Bgra8UnormSrgb
12106            | wgpu::TextureFormat::Rgba16Unorm
12107            | wgpu::TextureFormat::Rgba16Float
12108            | wgpu::TextureFormat::Rgba32Float
12109            | wgpu::TextureFormat::Rgb10a2Unorm
12110    )
12111}
12112
12113fn native_format_supported(format: wgpu::TextureFormat, features: wgpu::Features) -> bool {
12114    if !is_filterable_color_format(format) {
12115        return false;
12116    }
12117    match format {
12118        wgpu::TextureFormat::Rgba16Unorm => {
12119            features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
12120        }
12121        wgpu::TextureFormat::Rgba32Float => features.contains(wgpu::Features::FLOAT32_FILTERABLE),
12122        _ => true,
12123    }
12124}
12125
12126fn validate_sampler_descriptor(
12127    device: &wgpu::Device,
12128    sampler: &wgpu::SamplerDescriptor<'_>,
12129) -> anyhow::Result<()> {
12130    if sampler.compare.is_some()
12131        || !matches!(
12132            sampler.mag_filter,
12133            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12134        )
12135        || !matches!(
12136            sampler.min_filter,
12137            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12138        )
12139        || sampler.anisotropy_clamp == 0
12140        || !sampler.lod_min_clamp.is_finite()
12141        || !sampler.lod_max_clamp.is_finite()
12142        || sampler.lod_min_clamp < 0.0
12143        || sampler.lod_max_clamp < sampler.lod_min_clamp
12144    {
12145        anyhow::bail!("native sampler must be non-comparison linear filtering with finite LODs");
12146    }
12147    if (matches!(sampler.address_mode_u, wgpu::AddressMode::ClampToBorder)
12148        || matches!(sampler.address_mode_v, wgpu::AddressMode::ClampToBorder)
12149        || matches!(sampler.address_mode_w, wgpu::AddressMode::ClampToBorder))
12150        && !device
12151            .features()
12152            .contains(wgpu::Features::ADDRESS_MODE_CLAMP_TO_BORDER)
12153    {
12154        anyhow::bail!("native sampler uses unsupported clamp-to-border addressing");
12155    }
12156    Ok(())
12157}
12158
12159fn validate_texture_dimensions(
12160    device: &wgpu::Device,
12161    width: u32,
12162    height: u32,
12163) -> anyhow::Result<()> {
12164    if width == 0
12165        || height == 0
12166        || width > device.limits().max_texture_dimension_2d
12167        || height > device.limits().max_texture_dimension_2d
12168    {
12169        anyhow::bail!("texture dimensions are outside the device limit");
12170    }
12171    Ok(())
12172}
12173
12174fn validate_image_handle(handle: u64) -> anyhow::Result<()> {
12175    if handle == 0 {
12176        anyhow::bail!("image handle 0 is reserved");
12177    }
12178    Ok(())
12179}
12180
12181/// Decoder limits for encoded uploads. `image::Limits::default()` only caps
12182/// allocations at 512 MiB, which a few hundred KB of hostile PNG can reach;
12183/// the dimension cap plus a decode-sized allocation cap keep the transient
12184/// inside the same envelope as the retained image budget.
12185fn decode_limits(max_dimension: u32) -> image::Limits {
12186    let mut limits = image::Limits::default();
12187    limits.max_image_width = Some(max_dimension);
12188    limits.max_image_height = Some(max_dimension);
12189    limits.max_alloc = Some(MAX_IMAGE_DECODE_BYTES);
12190    limits
12191}
12192
12193/// Decode an encoded image to RGBA8, rejecting oversized input *before* the
12194/// decoder allocates. The dimension probe runs against a separate reader
12195/// because `into_dimensions` consumes it.
12196fn decode_image_rgba8(data: &[u8], max_dimension: u32) -> anyhow::Result<(u32, u32, Vec<u8>)> {
12197    let format = image::guess_format(data)?;
12198    let (w, h) = {
12199        let mut probe = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12200        probe.limits(decode_limits(max_dimension));
12201        probe.into_dimensions()?
12202    };
12203    validate_image_decode_budget(w, h, max_dimension)?;
12204    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12205    reader.limits(decode_limits(max_dimension));
12206    let rgba = reader.decode()?.into_rgba8();
12207    Ok((w, h, rgba.into_raw()))
12208}
12209
12210fn validate_image_decode_budget(w: u32, h: u32, max_dimension: u32) -> anyhow::Result<()> {
12211    if w == 0 || h == 0 {
12212        anyhow::bail!("encoded image has zero dimensions");
12213    }
12214    if w > max_dimension || h > max_dimension {
12215        anyhow::bail!("encoded image {w}x{h} exceeds the device texture limit {max_dimension}");
12216    }
12217    let bytes = checked_image_bytes(w, h, 4)?;
12218    if bytes > MAX_IMAGE_DECODE_BYTES {
12219        anyhow::bail!(
12220            "encoded image {w}x{h} needs {bytes} bytes to decode, over the {MAX_IMAGE_DECODE_BYTES} byte limit"
12221        );
12222    }
12223    Ok(())
12224}
12225
12226fn checked_image_bytes(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<u64> {
12227    if w == 0 || h == 0 || bytes_per_pixel == 0 {
12228        anyhow::bail!("image dimensions and bytes per pixel must be non-zero");
12229    }
12230    (w as u64)
12231        .checked_mul(h as u64)
12232        .and_then(|size| size.checked_mul(bytes_per_pixel as u64))
12233        .ok_or_else(|| anyhow::anyhow!("image byte size overflow"))
12234}
12235
12236fn texture_storage_bytes(
12237    format: wgpu::TextureFormat,
12238    width: u32,
12239    height: u32,
12240) -> anyhow::Result<u64> {
12241    if width == 0 || height == 0 {
12242        anyhow::bail!("texture dimensions must be non-zero");
12243    }
12244    let bytes = format.theoretical_memory_footprint(wgpu::Extent3d {
12245        width,
12246        height,
12247        depth_or_array_layers: 1,
12248    });
12249    if bytes == 0 {
12250        anyhow::bail!("texture format has no storage size");
12251    }
12252    Ok(bytes)
12253}
12254
12255fn texture_storage_bytes_with_samples(
12256    format: wgpu::TextureFormat,
12257    width: u32,
12258    height: u32,
12259    sample_count: u32,
12260) -> anyhow::Result<u64> {
12261    texture_storage_bytes(format, width, height)?
12262        .checked_mul(u64::from(sample_count.max(1)))
12263        .ok_or_else(|| anyhow::anyhow!("multisample texture size overflow"))
12264}
12265
12266fn checked_image_bytes_usize(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<usize> {
12267    usize::try_from(checked_image_bytes(w, h, bytes_per_pixel)?)
12268        .map_err(|_| anyhow::anyhow!("image byte size exceeds addressable memory"))
12269}
12270
12271fn clamp_scissor(x: u32, y: u32, w: u32, h: u32, tw: u32, th: u32) -> (u32, u32, u32, u32) {
12272    if w == 0 || h == 0 || tw == 0 || th == 0 {
12273        return (0, 0, 0, 0);
12274    }
12275    let x = x.min(tw - 1);
12276    let y = y.min(th - 1);
12277    let w = w.min(tw - x);
12278    let h = h.min(th - y);
12279    (x, y, w, h)
12280}
12281
12282fn rect_to_scissor(r: repose_core::Rect, width: u32, height: u32) -> (u32, u32, u32, u32) {
12283    if width == 0
12284        || height == 0
12285        || !r.x.is_finite()
12286        || !r.y.is_finite()
12287        || !r.w.is_finite()
12288        || !r.h.is_finite()
12289        || r.w <= 0.0
12290        || r.h <= 0.0
12291    {
12292        return (0, 0, 0, 0);
12293    }
12294    // Inward, for the same reason as `to_scissor`: a clip covers only whole
12295    // pixels inside its rect.
12296    let x0 = r.x.ceil().max(0.0).min(width as f32);
12297    let y0 = r.y.ceil().max(0.0).min(height as f32);
12298    let x1 = (r.x + r.w).floor().max(x0).min(width as f32);
12299    let y1 = (r.y + r.h).floor().max(y0).min(height as f32);
12300    if x1 <= x0 || y1 <= y0 {
12301        return (0, 0, 0, 0);
12302    }
12303    (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
12304}
12305
12306fn rect_to_ndc(r: repose_core::Rect, width: f32, height: f32) -> [f32; 4] {
12307    if width <= 0.0 || height <= 0.0 || !r.x.is_finite() || !r.y.is_finite() {
12308        return [0.0, 0.0, 0.0, 0.0];
12309    }
12310    let x0 = r.x / width * 2.0 - 1.0;
12311    let y0 = 1.0 - r.y / height * 2.0;
12312    let x1 = (r.x + r.w) / width * 2.0 - 1.0;
12313    let y1 = 1.0 - (r.y + r.h) / height * 2.0;
12314    [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]
12315}
12316
12317fn parent_rect_to_layer_rect(
12318    rect: repose_core::Rect,
12319    parent_transform: &Transform,
12320    origin: (f32, f32),
12321) -> Option<repose_core::Rect> {
12322    let inverse = parent_transform.inverse_linear()?;
12323    let tx = parent_transform.translate_x;
12324    let ty = parent_transform.translate_y;
12325    let corners = [
12326        (rect.x, rect.y),
12327        (rect.x + rect.w, rect.y),
12328        (rect.x, rect.y + rect.h),
12329        (rect.x + rect.w, rect.y + rect.h),
12330    ];
12331    let mut min_x = f32::INFINITY;
12332    let mut min_y = f32::INFINITY;
12333    let mut max_x = f32::NEG_INFINITY;
12334    let mut max_y = f32::NEG_INFINITY;
12335    for (x, y) in corners {
12336        let local_x = x - tx;
12337        let local_y = y - ty;
12338        let transformed_x = inverse[0] * local_x + inverse[1] * local_y;
12339        let transformed_y = inverse[2] * local_x + inverse[3] * local_y;
12340        min_x = min_x.min(transformed_x);
12341        min_y = min_y.min(transformed_y);
12342        max_x = max_x.max(transformed_x);
12343        max_y = max_y.max(transformed_y);
12344    }
12345    let result = repose_core::Rect {
12346        x: min_x - origin.0,
12347        y: min_y - origin.1,
12348        w: (max_x - min_x).max(0.0),
12349        h: (max_y - min_y).max(0.0),
12350    };
12351    (result.x.is_finite() && result.y.is_finite() && result.w.is_finite() && result.h.is_finite())
12352        .then_some(result)
12353}
12354
12355fn parent_radii_to_layer_radii(radii: [f32; 4], parent_transform: &Transform) -> Option<[f32; 4]> {
12356    let m = parent_transform.linear();
12357    let sx = (m[0] * m[0] + m[2] * m[2]).sqrt();
12358    let sy = (m[1] * m[1] + m[3] * m[3]).sqrt();
12359    (sx.is_finite() && sy.is_finite() && sx > 1e-6 && sy > 1e-6)
12360        .then(|| [radii[0] / sx, radii[1] / sy, radii[2] / sx, radii[3] / sy])
12361}
12362
12363fn parent_affine_to_layer_affine(
12364    affine: [f32; 6],
12365    parent_transform: &Transform,
12366    origin: (f32, f32),
12367) -> Option<[f32; 6]> {
12368    let inverse = parent_transform.inverse_linear()?;
12369    let tx = affine[4] - parent_transform.translate_x;
12370    let ty = affine[5] - parent_transform.translate_y;
12371    Some([
12372        inverse[0] * affine[0] + inverse[1] * affine[2],
12373        inverse[0] * affine[1] + inverse[1] * affine[3],
12374        inverse[2] * affine[0] + inverse[3] * affine[2],
12375        inverse[2] * affine[1] + inverse[3] * affine[3],
12376        inverse[0] * tx + inverse[1] * ty - origin.0,
12377        inverse[2] * tx + inverse[3] * ty - origin.1,
12378    ])
12379}
12380
12381fn checked_copy_region(
12382    region: repose_core::Rect,
12383    target_width: u32,
12384    target_height: u32,
12385) -> Option<(u32, u32, u32, u32)> {
12386    if target_width == 0
12387        || target_height == 0
12388        || !region.x.is_finite()
12389        || !region.y.is_finite()
12390        || !region.w.is_finite()
12391        || !region.h.is_finite()
12392        || region.w <= 0.0
12393        || region.h <= 0.0
12394    {
12395        return None;
12396    }
12397    let x0 = region.x.floor().max(0.0).min(target_width as f32) as u32;
12398    let y0 = region.y.floor().max(0.0).min(target_height as f32) as u32;
12399    let x1 = (region.x + region.w)
12400        .ceil()
12401        .max(x0 as f32)
12402        .min(target_width as f32) as u32;
12403    let y1 = (region.y + region.h)
12404        .ceil()
12405        .max(y0 as f32)
12406        .min(target_height as f32) as u32;
12407    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
12408}
12409
12410fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
12411    let x0 = a.x.max(b.x);
12412    let y0 = a.y.max(b.y);
12413    let x1 = (a.x + a.w).min(b.x + b.w);
12414    let y1 = (a.y + a.h).min(b.y + b.h);
12415    repose_core::Rect {
12416        x: x0,
12417        y: y0,
12418        w: (x1 - x0).max(0.0),
12419        h: (y1 - y0).max(0.0),
12420    }
12421}
12422
12423#[cfg(test)]
12424mod tests {
12425    use super::*;
12426
12427    #[test]
12428    fn image_source_rect_uses_texel_edges() {
12429        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 3, 2)), 10, 8)
12430            .expect("source should be valid");
12431        assert_eq!(source.uv_min, [0.2, 0.125]);
12432        assert_eq!(source.uv_max, [0.5, 0.375]);
12433        assert_eq!(source.width, 3.0);
12434        assert_eq!(source.height, 2.0);
12435        assert!(resolve_image_source(Some(ImageSourceRect::new(8, 0, 3, 1)), 10, 8).is_none());
12436        assert!(resolve_image_source(Some(ImageSourceRect::new(0, 0, 0, 1)), 10, 8).is_none());
12437    }
12438
12439    #[test]
12440    fn image_fit_stays_inside_selected_source() {
12441        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 4, 2)), 10, 8)
12442            .expect("source should be valid");
12443        let rect = repose_core::Rect {
12444            x: 20.0,
12445            y: 30.0,
12446            w: 8.0,
12447            h: 8.0,
12448        };
12449        let (_, uv) = image_fit_geometry(rect, ImageFit::Cover, ImageAlignment::Center, &source)
12450            .expect("cover geometry should be valid");
12451        assert!(uv[0] >= source.uv_min[0] && uv[2] <= source.uv_max[0]);
12452        assert!(uv[1] >= source.uv_min[1] && uv[3] <= source.uv_max[1]);
12453    }
12454
12455    #[test]
12456    fn ring_alignment_rejects_bad_input() {
12457        assert_eq!(align_up(0, 4).unwrap(), 0);
12458        assert_eq!(align_up(1, 4).unwrap(), 4);
12459        assert!(align_up(1, 3).is_err());
12460        assert!(align_up(u64::MAX, 4).is_err());
12461    }
12462
12463    /// Every stride must be a legal dynamic offset and a legal `align_up`
12464    /// alignment at every alignment a driver may report.
12465    #[test]
12466    fn mesh_uniform_stride_is_always_a_power_of_two() {
12467        for base in [4u64, 16, 32, 64, 128, 256, 512] {
12468            for size in [
12469                std::mem::size_of::<MeshUniform>() as u64,
12470                96,
12471                272,
12472                288,
12473                1024,
12474            ] {
12475                let stride = mesh_uniform_stride(size, base);
12476                assert!(
12477                    stride.is_power_of_two(),
12478                    "stride {stride} for size {size} base {base} is not a power of two"
12479                );
12480                assert_eq!(stride % base, 0, "stride {stride} misaligned to {base}");
12481                assert!(stride >= size, "stride {stride} below size {size}");
12482                assert!(
12483                    align_up(size, stride).is_ok(),
12484                    "capacity alignment rejects stride {stride}"
12485                );
12486            }
12487        }
12488    }
12489
12490    /// Byte layout of one `MeshUniform`, as the shader sees it.
12491    fn wgsl_type_size(module: &naga::Module, ty: naga::Handle<naga::Type>) -> u32 {
12492        match &module.types[ty].inner {
12493            naga::TypeInner::Scalar(_) | naga::TypeInner::Atomic(_) => 4,
12494            naga::TypeInner::Vector { size, .. } => 4 * *size as u32,
12495            naga::TypeInner::Array {
12496                size: naga::ArraySize::Constant(count),
12497                stride,
12498                ..
12499            } => stride * count.get(),
12500            _ => 0,
12501        }
12502    }
12503
12504    /// `shaders/mesh.wgsl` and `MeshUniform` are two halves of one contract:
12505    /// the shader reads the struct by byte offset, so any member added,
12506    /// dropped, resized or reordered on one side only shows up as scrambled
12507    /// gradient uniforms. Compare member offsets (and the total size) directly.
12508    #[test]
12509    fn mesh_uniform_matches_the_wgsl_member_layout() {
12510        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/mesh.wgsl");
12511        let module = naga::front::wgsl::parse_str(&std::fs::read_to_string(path).expect("read"))
12512            .expect("mesh.wgsl parses");
12513        let wgsl = module
12514            .types
12515            .iter()
12516            .filter(|(_, ty)| ty.name.as_deref() == Some("MeshUniform"))
12517            .find_map(|(_, ty)| match &ty.inner {
12518                naga::TypeInner::Struct { members, .. } => Some(members.as_slice()),
12519                _ => None,
12520            })
12521            .expect("mesh.wgsl declares MeshUniform");
12522        let rust = [
12523            ("m0", std::mem::offset_of!(MeshUniform, m0)),
12524            ("m1", std::mem::offset_of!(MeshUniform, m1)),
12525            ("paint", std::mem::offset_of!(MeshUniform, paint)),
12526            ("color0", std::mem::offset_of!(MeshUniform, color0)),
12527            ("color1", std::mem::offset_of!(MeshUniform, color1)),
12528            ("grad_start", std::mem::offset_of!(MeshUniform, grad_start)),
12529            ("grad_end", std::mem::offset_of!(MeshUniform, grad_end)),
12530            (
12531                "stop_colors",
12532                std::mem::offset_of!(MeshUniform, stop_colors),
12533            ),
12534            (
12535                "stop_offsets",
12536                std::mem::offset_of!(MeshUniform, stop_offsets),
12537            ),
12538        ];
12539        assert_eq!(wgsl.len(), rust.len(), "MeshUniform member count");
12540        for (member, (name, offset)) in wgsl.iter().zip(rust) {
12541            assert_eq!(member.name.as_deref(), Some(name), "member order");
12542            assert_eq!(member.offset as usize, offset, "offset of {name}");
12543        }
12544        let last = wgsl.last().expect("non-empty");
12545        let wgsl_size = last.offset as usize + wgsl_type_size(&module, last.ty) as usize;
12546        assert_eq!(wgsl_size, std::mem::size_of::<MeshUniform>());
12547        assert_eq!(wgsl_size, MESH_UNIFORM_BYTES);
12548    }
12549
12550    /// The stop table rides in the same uniform as the two-point ramps, so the
12551    /// packing invariants the shader relies on (count, clamped non-decreasing
12552    /// offsets, colours left in linear space) are checked where they are built.
12553    #[test]
12554    fn gradient_stops_pack_clamped_and_monotonic() {
12555        let stops = GradientStops::from_slice(&[
12556            repose_core::GradientStop {
12557                offset: 0.25,
12558                color: repose_core::Color::from_rgba(255, 0, 0, 255),
12559            },
12560            repose_core::GradientStop {
12561                offset: f32::NAN,
12562                color: repose_core::Color::from_rgba(0, 255, 0, 255),
12563            },
12564            repose_core::GradientStop {
12565                offset: -2.0,
12566                color: repose_core::Color::from_rgba(0, 0, 255, 255),
12567            },
12568            repose_core::GradientStop {
12569                offset: 9.0,
12570                color: repose_core::Color::from_rgba(255, 255, 255, 255),
12571            },
12572        ]);
12573        let (colors, offsets, count) = pack_gradient_stops(&stops);
12574        assert_eq!(count, 4);
12575        assert_eq!(offsets, [[0.25, 0.25, 0.25, 1.0], [0.0, 0.0, 0.0, 0.0]]);
12576        assert_eq!(colors[0], [1.0, 0.0, 0.0, 1.0]);
12577        assert_eq!(colors[1], [0.0, 1.0, 0.0, 1.0]);
12578        assert_eq!(colors[2], [0.0, 0.0, 1.0, 1.0]);
12579        assert_eq!(colors[3], [1.0, 1.0, 1.0, 1.0]);
12580        assert_eq!(
12581            pack_gradient_stops(&GradientStops::default()).2,
12582            0,
12583            "an empty table paints nothing"
12584        );
12585    }
12586
12587    /// A repack moves every surviving glyph, so eviction must never touch one
12588    /// the in-flight frame already holds UVs for, and must never empty the map.
12589    /// Glyph 1 is the coldest overall but in flight, so it has to survive even
12590    /// though glyphs 3 and 4 are warmer and evictable.
12591    #[test]
12592    fn atlas_eviction_spares_the_frame_in_flight() {
12593        fn key(glyph_id: u32) -> repose_text::CacheKey {
12594            repose_text::CacheKey {
12595                font_id: 1,
12596                glyph_id,
12597                font_size_bits: 16.0f32.to_bits(),
12598                variation: None,
12599            }
12600        }
12601        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12602        for (glyph_id, last_touch, last_used_frame) in
12603            [(1u32, 10u64, 9u64), (2, 20, 7), (3, 30, 7), (4, 40, 7)]
12604        {
12605            map.insert(
12606                key(glyph_id),
12607                GlyphInfo {
12608                    u0: 0.0,
12609                    v0: 0.0,
12610                    u1: 0.1,
12611                    v1: 0.1,
12612                    w: 10.0,
12613                    h: 10.0,
12614                    last_touch,
12615                    last_used_frame,
12616                },
12617            );
12618        }
12619        let mut used_area = 400;
12620        assert!(evict_atlas_lru(&mut map, &mut used_area, 9, 250));
12621        assert_eq!(used_area, 200);
12622        assert!(map.contains_key(&key(1)), "in-flight glyph was evicted");
12623        assert!(!map.contains_key(&key(2)) && !map.contains_key(&key(3)));
12624        assert!(map.contains_key(&key(4)));
12625    }
12626
12627    #[test]
12628    fn atlas_eviction_refuses_to_empty_the_atlas() {
12629        let only = repose_text::CacheKey {
12630            font_id: 1,
12631            glyph_id: 1,
12632            font_size_bits: 16.0f32.to_bits(),
12633            variation: None,
12634        };
12635        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12636        map.insert(
12637            only,
12638            GlyphInfo {
12639                u0: 0.0,
12640                v0: 0.0,
12641                u1: 0.1,
12642                v1: 0.1,
12643                w: 10.0,
12644                h: 10.0,
12645                last_touch: 1,
12646                last_used_frame: 1,
12647            },
12648        );
12649        let mut used_area = 100;
12650        assert!(!evict_atlas_lru(&mut map, &mut used_area, 2, 0));
12651        assert_eq!(map.len(), 1);
12652        assert_eq!(used_area, 100);
12653    }
12654
12655    #[test]
12656    fn empty_and_negative_rects_are_suppressed() {
12657        assert_eq!(
12658            rect_to_scissor(
12659                repose_core::Rect {
12660                    x: 0.0,
12661                    y: 0.0,
12662                    w: 0.0,
12663                    h: 4.0,
12664                },
12665                16,
12666                16,
12667            ),
12668            (0, 0, 0, 0)
12669        );
12670        assert_eq!(
12671            rect_to_scissor(
12672                repose_core::Rect {
12673                    x: 0.0,
12674                    y: 0.0,
12675                    w: -1.0,
12676                    h: 4.0,
12677                },
12678                16,
12679                16,
12680            ),
12681            (0, 0, 0, 0)
12682        );
12683    }
12684
12685    #[test]
12686    fn copy_region_is_checked() {
12687        assert_eq!(
12688            checked_copy_region(
12689                repose_core::Rect {
12690                    x: 2.25,
12691                    y: 3.5,
12692                    w: 4.0,
12693                    h: 5.0,
12694                },
12695                16,
12696                16,
12697            ),
12698            Some((2, 3, 5, 6))
12699        );
12700        assert_eq!(
12701            checked_copy_region(
12702                repose_core::Rect {
12703                    x: 20.0,
12704                    y: 0.0,
12705                    w: 2.0,
12706                    h: 2.0,
12707                },
12708                16,
12709                16,
12710            ),
12711            None
12712        );
12713    }
12714
12715    #[test]
12716    fn decode_budget_rejects_oversized_encoded_images() {
12717        assert!(validate_image_decode_budget(64, 64, 8192).is_ok());
12718        assert!(validate_image_decode_budget(0, 64, 8192).is_err());
12719        assert!(validate_image_decode_budget(64, 0, 8192).is_err());
12720        assert!(validate_image_decode_budget(8193, 1, 8192).is_err());
12721        assert!(validate_image_decode_budget(1, 8193, 8192).is_err());
12722        // Fits the device, but not the decode allocation budget.
12723        assert!(validate_image_decode_budget(8192, 4096, 8192).is_err());
12724    }
12725
12726    #[test]
12727    fn only_wgpu_supported_sample_counts_are_accepted() {
12728        for samples in [1, 2, 4, 8, 16] {
12729            assert!(validate_sample_count("msaa_samples", samples).is_ok());
12730        }
12731        for samples in [0, 3, 5, 7, 9, 17, 32, u32::MAX] {
12732            assert!(
12733                validate_sample_count("msaa_samples", samples).is_err(),
12734                "{samples} must be rejected"
12735            );
12736        }
12737    }
12738
12739    #[test]
12740    fn p010_uses_ten_bit_sample_range() {
12741        let raw = make_yuv_transform_raw(ColorInfo::default(), PixelFormat::P010);
12742        let expected = 65535.0 / (64.0 * 1020.0);
12743        assert!((raw.b[3] - expected).abs() < 1e-6);
12744        assert_eq!(canonical_fourcc(PixelFormat::P010), P010_FOURCC);
12745        assert_eq!(canonical_fourcc(PixelFormat::Nv12), NV12_FOURCC);
12746        assert_eq!(
12747            make_yuv_transform_raw(
12748                ColorInfo {
12749                    transfer: repose_core::color::Transfer::Pq,
12750                    ..ColorInfo::default()
12751                },
12752                PixelFormat::Nv12,
12753            )
12754            .transfer[0],
12755            3.0
12756        );
12757        assert_eq!(
12758            make_yuv_transform_raw(
12759                ColorInfo {
12760                    transfer: repose_core::color::Transfer::Hlg,
12761                    ..ColorInfo::default()
12762                },
12763                PixelFormat::Nv12,
12764            )
12765            .transfer[0],
12766            4.0
12767        );
12768    }
12769}