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, ScenePassOps};
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    /// Per-placement transforms, one record per mesh instance. A lone mesh
283    /// writes a single record and draws `0..1`; an instanced run writes N
284    /// contiguous records and draws `0..N` against one shared paint uniform.
285    mesh_instance_buf: wgpu::Buffer,
286    mesh_instance_head: u64,
287    mesh_instance_cap: u64,
288
289    /// Translator-owned flatten layer ids used by the previous frame;
290    /// drained from the layer pool at the start of each translation (they
291    /// are single-frame by construction).
292    flatten_layer_ids: Vec<u32>,
293
294    /// Layer ids handed out to separable-blur scratches, keyed by
295    /// `(source layer, slot)` so they survive the stale-layer sweep and stay
296    /// bound across frames. `blur_scratch_next` is the handout counter for
297    /// those ids; it only ever grows, so a freed slot's id is never reissued.
298    blur_scratch_ids: HashMap<(u32, u32), u32>,
299    blur_scratch_next: u32,
300
301    /// Backdrop snapshots keyed by isolated-blend layer id. Filled during
302    /// translation (texture allocated) and populated by a texture copy at
303    /// execution time, before the blend composite draws.
304    blend_snapshots: std::collections::HashMap<u32, BlendSnapshot>,
305    blend_snapshot_pool: HashMap<BlendSnapshotKey, BlendSnapshot>,
306    /// (blend layer id, parent target) copies to run before the pass that
307    /// composites the blend. Executed between passes: copies the current
308    /// target region into the snapshot texture.
309    blend_copies: Vec<BlendCopy>,
310
311    msaa_samples: u32,
312    working_space_msaa_samples: u32,
313
314    // Depth-stencil target
315    depth_stencil_tex: wgpu::Texture,
316    depth_stencil_view: wgpu::TextureView,
317
318    // Optional MSAA color target
319    msaa_tex: Option<wgpu::Texture>,
320    msaa_view: Option<wgpu::TextureView>,
321    ws_msaa_tex: Option<wgpu::Texture>,
322    ws_msaa_view: Option<wgpu::TextureView>,
323    surface_resolve_tex: Option<wgpu::Texture>,
324    surface_resolve_view: Option<wgpu::TextureView>,
325    surface_resolve_bytes: u64,
326    msaa_bytes: u64,
327    ws_msaa_bytes: u64,
328    depth_stencil_bytes: u64,
329    working_space_bytes: u64,
330    gpu_budget_bytes: u64,
331
332    globals_buf: wgpu::Buffer,
333    globals_bind: wgpu::BindGroup,
334    globals_staging: wgpu::Buffer,
335    globals_staging_cap: u64,
336
337    // Glyph atlas
338    atlas_mask: AtlasA8,
339    atlas_color: AtlasRGBA,
340    atlas_mask_bind: wgpu::BindGroup,
341    atlas_color_bind: wgpu::BindGroup,
342    atlas_generation: u64,
343    atlas_mask_pending: AtlasPending,
344    atlas_color_pending: AtlasPending,
345    atlas_last_compact_frame: u64,
346    atlas_mask_failures: HashSet<repose_text::CacheKey>,
347    atlas_color_failures: HashSet<repose_text::CacheKey>,
348    /// Glyphs that rasterized fine but had no room. Skipped until the next
349    /// repack so a full atlas does not re-rasterize every frame.
350    atlas_mask_deferred: HashSet<repose_text::CacheKey>,
351    atlas_color_deferred: HashSet<repose_text::CacheKey>,
352
353    glyph_outline_cache: HashMap<repose_text::CacheKey, GlyphOutlineEntry>,
354    legacy_shaped_text_cache: HashMap<TextShapeKey, TextShapeCacheEntry>,
355
356    // Image management
357    next_image_handle: u64,
358    images: HashMap<u64, ImageTex>,
359    retained: HashMap<u64, RetainedImage>,
360    retained_bytes_total: u64,
361
362    // A8 coverage-tile management (host-rasterized masks composited tinted;
363    // no retained CPU copies - tiles are immutable and re-registered).
364    next_coverage_handle: u64,
365    coverages: HashMap<u64, CoverageTex>,
366
367    // Eviction stats
368    frame_index: u64,
369    cache_touch_clock: u64,
370    image_bytes_total: u64,
371    image_evict_after_frames: u64,
372    image_budget_bytes: u64,
373
374    // Graphics layer pool. Maps `SceneNode::BeginLayer::layer_id` to a
375    // cached offscreen render target.
376    layer_pool: HashMap<u32, LayerTarget>,
377    producer_layer_ids: HashSet<u32>,
378    layer_bytes_total: u64,
379    transient_layer_bytes_total: u64,
380
381    // Linear working-space mode (default off -> fast playback path).
382    // When enabled, the scene is rendered into an Rgba16Float intermediate
383    // texture, then a final full-screen pass applies the display OETF.
384    working_space: bool,
385    ws_tex: Option<wgpu::Texture>,
386    ws_view: Option<wgpu::TextureView>,
387    ws_bind: Option<wgpu::BindGroup>,
388    display_pipeline: Option<wgpu::RenderPipeline>,
389    display_layout: Option<wgpu::BindGroupLayout>,
390
391    pub callback_resources: CallbackResources,
392    callback_scoped_resources: HashMap<CallbackScopeKey, CallbackResources>,
393    callback_scope_uses: HashMap<CallbackScopeKey, CallbackScopeUse>,
394    callback_scope_payloads: HashMap<CallbackScopeKey, Weak<Callback>>,
395    callback_scope_clock: u64,
396    blend_snapshot_bytes_total: u64,
397    frame_active: bool,
398    last_render_error: Option<String>,
399}
400
401pub struct WgpuSurfaceBackend {
402    #[cfg(feature = "winit-surface")]
403    instance: Option<wgpu::Instance>,
404    #[cfg(feature = "winit-surface")]
405    window: Option<Arc<winit::window::Window>>,
406    pub surface: Option<wgpu::Surface<'static>>,
407    pub surface_config: Option<wgpu::SurfaceConfiguration>,
408    #[cfg_attr(not(feature = "winit-surface"), allow(dead_code))]
409    pending_reconfigure: bool,
410    pub renderer: WgpuSceneRenderer,
411    device_lost: Arc<Mutex<Option<(wgpu::DeviceLostReason, String)>>>,
412}
413
414impl std::ops::Deref for WgpuSurfaceBackend {
415    type Target = WgpuSceneRenderer;
416    fn deref(&self) -> &Self::Target {
417        &self.renderer
418    }
419}
420impl std::ops::DerefMut for WgpuSurfaceBackend {
421    fn deref_mut(&mut self) -> &mut Self::Target {
422        &mut self.renderer
423    }
424}
425
426#[cfg(feature = "winit-surface")]
427pub type WgpuBackend = WgpuSurfaceBackend;
428
429/// Largest `(width, height)` not exceeding `max` on either axis while keeping
430/// the requested aspect ratio.
431///
432/// Clamping the two axes independently rescales them by different factors, so
433/// "CSS pixels to viewport pixels" stops being a single number: the scene is
434/// projected against one shape while input is resolved against another, which
435/// shows up as a touch offset that grows with distance from the origin. One
436/// uniform factor keeps layout, hit testing and the framebuffer on the same
437/// scale, leaving a uniform loss of resolution.
438pub fn clamp_output_size(width: u32, height: u32, max: u32) -> (u32, u32) {
439    if width == 0 || height == 0 {
440        return (0, 0);
441    }
442    let max = max.max(1);
443    if width <= max && height <= max {
444        return (width, height);
445    }
446    let scale = max as f64 / width.max(height) as f64;
447    let clamped = |v: u32| ((v as f64 * scale).round() as u32).clamp(1, max);
448    (clamped(width), clamped(height))
449}
450
451/// Compose/Skia radius -> gaussian sigma (the `1 / sqrt(3)` the high-quality
452/// software mask blur applies).
453fn blur_sigma(radius: f32) -> f32 {
454    (radius.max(0.0) * 0.577_350_3 + 0.5).max(0.0)
455}
456
457/// Padding needed on each side of a layer for a separable blur to fit its
458/// 3-sigma support without clipping the kernel.
459fn blur_pad(radius: f32) -> f32 {
460    (blur_sigma(radius) * 3.0).ceil().max(0.0)
461}
462
463/// First translator-owned blur-scratch layer id. Producer ids start at 1 per
464/// scene and flatten owns `FLATTEN_ID_BASE`, so this range never collides.
465const BLUR_SCRATCH_ID_BASE: u32 = 0xE000_0000;
466
467/// Scratch slot for the `Modifier::blur` half of a layer's content.
468const BLUR_SLOT_CONTENT: u32 = 0;
469/// Scratch slot for the shadow's half-plane kernel, so a layer can carry a
470/// content blur and a shadow without either pass clobbering the other's
471/// scratch.
472const BLUR_SLOT_SHADOW: u32 = 1;
473
474impl Drop for WgpuSceneRenderer {
475    fn drop(&mut self) {
476        let _ = self.device.poll(wgpu::PollType::Poll);
477        #[cfg(not(target_arch = "wasm32"))]
478        {
479            let _ = self.device.poll(wgpu::PollType::Wait {
480                submission_index: None,
481                timeout: Some(web_time::Duration::from_millis(100)),
482            });
483        }
484    }
485}
486
487#[derive(Clone)]
488struct LayerTarget {
489    texture: wgpu::Texture,
490    view: wgpu::TextureView,
491    bind: wgpu::BindGroup,
492    bind_linear: wgpu::BindGroup,
493    depth_stencil_view: wgpu::TextureView,
494    width: u32,
495    height: u32,
496    format: wgpu::TextureFormat,
497    bytes: u64,
498    rect_px: (f32, f32, f32, f32),
499    transient: bool,
500}
501
502/// Backdrop snapshot for one isolated blend: a copy of the current target
503/// region taken before the source layer is composited, sampled as the
504/// "backdrop" input of the blend shader.
505#[derive(Clone, Copy, PartialEq, Eq, Hash)]
506struct BlendSnapshotKey {
507    format: wgpu::TextureFormat,
508    width: u32,
509    height: u32,
510}
511
512#[derive(Clone)]
513struct BlendSnapshot {
514    key: BlendSnapshotKey,
515    texture: wgpu::Texture,
516    bind: wgpu::BindGroup,
517    bytes: u64,
518}
519
520struct BlendCopy {
521    pass_id: u64,
522    blend_id: u32,
523    target: PassTarget,
524    region: repose_core::Rect,
525}
526
527#[derive(Clone)]
528enum ActiveClip {
529    Rect {
530        off: u64,
531        cnt: u32,
532        rect: repose_core::Rect,
533        radii: [f32; 4],
534        /// Scissor the matching push drew its stencil pass with. The pop must
535        /// reuse it: a different (wider) bound decrements stencil the push
536        /// never incremented, leaving those pixels a level below the base so
537        /// every later `Equal` draw drops them.
538        scissor: (u32, u32, u32, u32),
539        difference: bool,
540        applied: bool,
541        blocked: bool,
542    },
543    Vector {
544        mesh: Option<MeshGpu>,
545        source: Arc<repose_core::VectorMeshData>,
546        uoff: u64,
547        /// The push's own instance run, replayed verbatim on the pop so the
548        /// decrement mask matches the increment bit for bit.
549        ioff: u64,
550        icnt: u32,
551        affine: [f32; 6],
552        scissor: (u32, u32, u32, u32),
553        difference: bool,
554        applied: bool,
555        blocked: bool,
556    },
557}
558
559impl ActiveClip {
560    fn difference(&self) -> bool {
561        match self {
562            Self::Rect { difference, .. } | Self::Vector { difference, .. } => *difference,
563        }
564    }
565
566    fn blocked(&self) -> bool {
567        match self {
568            Self::Rect { blocked, .. } | Self::Vector { blocked, .. } => *blocked,
569        }
570    }
571}
572
573/// Identifies which render target a `Pass` draws into.
574#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
575enum PassTarget {
576    Surface,
577    Layer(u32),
578}
579
580#[derive(Clone, Debug, PartialEq, Eq, Hash)]
581struct CallbackIdentity {
582    type_id: TypeId,
583    /// The callback's own key, kept whole: two colliding digests must not
584    /// share a resource bucket.
585    key: Option<Arc<str>>,
586    reusable: bool,
587}
588
589/// Identifies one callback's `CallbackResources` bucket. Deliberately
590/// excludes the target's size and scale: `prepare` runs every frame with
591/// the current [`ScreenDescriptor`], so any size-dependent resource
592/// belongs in the callback's own `ensure`. Keying the bucket by size
593/// would drop every callback's GPU state on a window resize - atlas
594/// contents, pipeline caches - leaving nothing to rebuild from.
595///
596/// A bucket still changes identity with the target, format or sample
597/// count, and is evicted once [`MAX_CALLBACK_SCOPES`] is exceeded, so
598/// anything loaded once in `prepare` has to be rebuildable from
599/// `prepare` alone.
600#[derive(Clone, Debug, PartialEq, Eq, Hash)]
601struct CallbackScopeKey {
602    callback: CallbackIdentity,
603    target: PassTarget,
604    target_format: wgpu::TextureFormat,
605    sample_count: u32,
606}
607
608#[derive(Clone, Copy)]
609struct CallbackScopeUse {
610    tick: u64,
611    frame: u64,
612}
613
614fn callback_identity(callback: &Callback, fallback: usize) -> CallbackIdentity {
615    match callback.0.resource_key() {
616        Some(key) => CallbackIdentity {
617            type_id: callback.0.resource_type_id(),
618            key: Some(Arc::from(key)),
619            reusable: true,
620        },
621        None => CallbackIdentity {
622            type_id: TypeId::of::<Callback>(),
623            key: Some(Arc::from(fallback.to_string())),
624            reusable: false,
625        },
626    }
627}
628
629fn callback_scope_key(
630    callback: &Callback,
631    fallback: usize,
632    target: PassTarget,
633    descriptor: &ScreenDescriptor,
634) -> CallbackScopeKey {
635    CallbackScopeKey {
636        callback: callback_identity(callback, fallback),
637        target,
638        target_format: descriptor.target_format,
639        sample_count: descriptor.sample_count,
640    }
641}
642
643/// Blend modes the GPU can express with fixed-function blending (plus
644/// [`Alpha`](repose_core::BlendMode::Alpha), the premultiplied default). Every
645/// other mode isolates into a graphics layer and composites with the
646/// `blend_layer` shader.
647const FIXED_FUNCTION_BLENDS: [repose_core::BlendMode; 5] = [
648    repose_core::BlendMode::Add,
649    repose_core::BlendMode::Multiply,
650    repose_core::BlendMode::Screen,
651    repose_core::BlendMode::Darken,
652    repose_core::BlendMode::Lighten,
653];
654
655/// Hardware blend state for a [`FIXED_FUNCTION_BLENDS`] mode, or `None` for
656/// `Alpha` and every mode that needs an isolated backdrop pass. Shared by the
657/// mesh and image pipelines so both composite identically.
658fn fixed_function_blend(mode: repose_core::BlendMode) -> Option<wgpu::BlendState> {
659    let premult_alpha = wgpu::BlendComponent::OVER;
660    let additive = wgpu::BlendComponent {
661        src_factor: wgpu::BlendFactor::One,
662        dst_factor: wgpu::BlendFactor::One,
663        operation: wgpu::BlendOperation::Add,
664    };
665    Some(match mode {
666        repose_core::BlendMode::Add => wgpu::BlendState {
667            color: additive,
668            alpha: additive,
669        },
670        repose_core::BlendMode::Multiply => wgpu::BlendState {
671            color: wgpu::BlendComponent {
672                src_factor: wgpu::BlendFactor::Dst,
673                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
674                operation: wgpu::BlendOperation::Add,
675            },
676            alpha: premult_alpha,
677        },
678        repose_core::BlendMode::Screen => wgpu::BlendState {
679            color: wgpu::BlendComponent {
680                src_factor: wgpu::BlendFactor::One,
681                dst_factor: wgpu::BlendFactor::OneMinusSrc,
682                operation: wgpu::BlendOperation::Add,
683            },
684            alpha: premult_alpha,
685        },
686        repose_core::BlendMode::Darken => wgpu::BlendState {
687            color: wgpu::BlendComponent {
688                src_factor: wgpu::BlendFactor::One,
689                dst_factor: wgpu::BlendFactor::One,
690                operation: wgpu::BlendOperation::Min,
691            },
692            alpha: premult_alpha,
693        },
694        repose_core::BlendMode::Lighten => wgpu::BlendState {
695            color: wgpu::BlendComponent {
696                src_factor: wgpu::BlendFactor::One,
697                dst_factor: wgpu::BlendFactor::One,
698                operation: wgpu::BlendOperation::Max,
699            },
700            alpha: premult_alpha,
701        },
702        _ => return None,
703    })
704}
705
706/// A bundle of render pipelines for a single sample-count target. Created
707/// twice: once with `sample_count = msaa_samples` for the surface pass, and
708/// once with `sample_count = 1` for graphics-layer render-to-texture passes
709/// (where MSAA is wasted).
710#[derive(Clone)]
711struct Pipelines {
712    rects: wgpu::RenderPipeline,
713    borders: wgpu::RenderPipeline,
714    ellipses: wgpu::RenderPipeline,
715    ellipse_borders: wgpu::RenderPipeline,
716    arcs: wgpu::RenderPipeline,
717    text_mask: wgpu::RenderPipeline,
718    text_color: wgpu::RenderPipeline,
719    image_rgba: wgpu::RenderPipeline,
720    /// Fixed-function blend variants of `image_rgba`, indexed like
721    /// `FIXED_FUNCTION_BLENDS`. `SceneNode::ImageBlended` with a separable
722    /// mode picks one here; the rest go through isolation.
723    image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
724    /// Tinted A8 coverage composite (`coverage.wgsl`): same vertex
725    /// attributes and bind groups as the text/color path, sampling a
726    /// single-channel tile registered with `register_coverage_a8`.
727    coverage: wgpu::RenderPipeline,
728    image_nv12: wgpu::RenderPipeline,
729    /// Fixed-function blend variants of `image_nv12`, indexed like
730    /// `FIXED_FUNCTION_BLENDS`.
731    image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
732    blur: wgpu::RenderPipeline,
733    blur_content: wgpu::RenderPipeline,
734    clip_bin: wgpu::RenderPipeline,
735    clip_dec: wgpu::RenderPipeline,
736    slug: Option<wgpu::RenderPipeline>,
737    /// Tessellated vector mesh (fill/stroke). Uses an `Equal` stencil compare
738    /// so world content is correctly masked to the active `PushVectorClip`
739    /// shape (outside any clip the stencil is 0 == ref 0, so it draws).
740    mesh: wgpu::RenderPipeline,
741    /// Screen-space overlay meshes: `LessEqual` compare so they always draw
742    /// regardless of any active vector clip.
743    mesh_overlay: wgpu::RenderPipeline,
744    /// Fixed-function blend variants of the mesh pipeline, selected by
745    /// `BlendMode`. Backdrop-dependent modes (overlay, color-dodge/burn,
746    /// hard/soft-light, exclusion, hue/saturation/color/luminosity) use the
747    /// `blend_layer` shader instead.
748    mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()],
749    /// Backdrop-blend composite: samples an isolated source layer and the
750    /// current target, applies the CSS blend formula selected by a uniform,
751    /// and composites premultiplied source-over.
752    blend_layer: wgpu::RenderPipeline,
753    /// Stencil increment for vector clips.
754    mesh_clip_inc: wgpu::RenderPipeline,
755    /// Stencil decrement for vector clips.
756    mesh_clip_dec: wgpu::RenderPipeline,
757    /// Projective layer composite (perspective flattening): samples a
758    /// graphics-layer texture through a 2D projective map. Drawn with a
759    /// `ProjectiveInstance` from `projective_ring`.
760    projective_layer: wgpu::RenderPipeline,
761}
762
763/// Vertex attributes for an instance buffer, as `(shader_location, byte
764/// offset, format)`. Deriving these by hand let struct field reordering
765/// silently desync the offsets from the layout.
766fn vattrs(spec: &[(u32, u64, wgpu::VertexFormat)]) -> Vec<wgpu::VertexAttribute> {
767    spec.iter()
768        .map(|&(shader_location, offset, format)| wgpu::VertexAttribute {
769            shader_location,
770            offset,
771            format,
772        })
773        .collect()
774}
775
776/// Byte offset of an instance field, so the attribute tables below stay tied
777/// to the struct layout instead of being maintained by hand.
778macro_rules! field_offset {
779    ($ty:ty, $field:ident) => {
780        std::mem::offset_of!($ty, $field) as u64
781    };
782}
783
784impl Pipelines {
785    fn create(
786        device: &wgpu::Device,
787        format: wgpu::TextureFormat,
788        sample_count: u32,
789        globals_layout: &wgpu::BindGroupLayout,
790        text_bind_layout: &wgpu::BindGroupLayout,
791        image_bind_layout_nv12: &wgpu::BindGroupLayout,
792        clip_pipeline_layout: &wgpu::PipelineLayout,
793        stencil_for_content: &wgpu::DepthStencilState,
794        stencil_for_clip_inc: &wgpu::DepthStencilState,
795        stencil_for_clip_dec: &wgpu::DepthStencilState,
796        clip_vertex_layout: &wgpu::VertexBufferLayout,
797        mesh_bind_layout: &wgpu::BindGroupLayout,
798    ) -> Self {
799        let msaa_state = wgpu::MultisampleState {
800            count: sample_count,
801            mask: !0,
802            alpha_to_coverage_enabled: false,
803        };
804
805        macro_rules! make_content_pipeline {
806            ($name:ident, $shader:literal, $inst_type:ty, $attrs:expr) => {
807                let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
808                    label: Some(concat!($shader, ".wgsl")),
809                    source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(concat!(
810                        env!("OUT_DIR"),
811                        "/",
812                        $shader,
813                        ".wgsl"
814                    )))),
815                });
816                let pipeline_layout =
817                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
818                        label: Some(concat!($shader, " pipeline layout")),
819                        bind_group_layouts: &[Some(globals_layout)],
820                        immediate_size: 0,
821                    });
822                let $name = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
823                    label: Some(concat!($shader, " pipeline")),
824                    layout: Some(&pipeline_layout),
825                    vertex: wgpu::VertexState {
826                        module: &shader_module,
827                        entry_point: Some("vs_main"),
828                        buffers: &[Some(wgpu::VertexBufferLayout {
829                            array_stride: std::mem::size_of::<$inst_type>() as u64,
830                            step_mode: wgpu::VertexStepMode::Instance,
831                            attributes: $attrs,
832                        })],
833                        compilation_options: wgpu::PipelineCompilationOptions::default(),
834                    },
835                    fragment: Some(wgpu::FragmentState {
836                        module: &shader_module,
837                        entry_point: Some("fs_main"),
838                        targets: &[Some(wgpu::ColorTargetState {
839                            format,
840                            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
841                            write_mask: wgpu::ColorWrites::ALL,
842                        })],
843                        compilation_options: wgpu::PipelineCompilationOptions::default(),
844                    }),
845                    primitive: wgpu::PrimitiveState::default(),
846                    depth_stencil: Some(stencil_for_content.clone()),
847                    multisample: msaa_state,
848                    multiview_mask: None,
849                    cache: None,
850                });
851            };
852        }
853
854        let rect_attrs = vattrs(&[
855            (
856                0,
857                field_offset!(RectInstance, xywh),
858                wgpu::VertexFormat::Float32x4,
859            ),
860            (
861                1,
862                field_offset!(RectInstance, radii),
863                wgpu::VertexFormat::Float32x4,
864            ),
865            (
866                2,
867                field_offset!(RectInstance, color0),
868                wgpu::VertexFormat::Float32x4,
869            ),
870            (
871                3,
872                field_offset!(RectInstance, color1),
873                wgpu::VertexFormat::Float32x4,
874            ),
875            (
876                4,
877                field_offset!(RectInstance, fwd_mat),
878                wgpu::VertexFormat::Float32x4,
879            ),
880            (
881                5,
882                field_offset!(RectInstance, grad),
883                wgpu::VertexFormat::Float32x4,
884            ),
885            (
886                6,
887                field_offset!(RectInstance, bits),
888                wgpu::VertexFormat::Uint32,
889            ),
890        ]);
891        let border_attrs = vattrs(&[
892            (
893                0,
894                field_offset!(BorderInstance, xywh),
895                wgpu::VertexFormat::Float32x4,
896            ),
897            (
898                1,
899                field_offset!(BorderInstance, radii),
900                wgpu::VertexFormat::Float32x4,
901            ),
902            (
903                2,
904                field_offset!(BorderInstance, color0),
905                wgpu::VertexFormat::Float32x4,
906            ),
907            (
908                3,
909                field_offset!(BorderInstance, color1),
910                wgpu::VertexFormat::Float32x4,
911            ),
912            (
913                4,
914                field_offset!(BorderInstance, fwd_mat),
915                wgpu::VertexFormat::Float32x4,
916            ),
917            (
918                5,
919                field_offset!(BorderInstance, grad),
920                wgpu::VertexFormat::Float32x4,
921            ),
922            (
923                6,
924                field_offset!(BorderInstance, bits),
925                wgpu::VertexFormat::Uint32,
926            ),
927            (
928                7,
929                field_offset!(BorderInstance, stroke),
930                wgpu::VertexFormat::Float32,
931            ),
932        ]);
933        let ellipse_attrs = vattrs(&[
934            (
935                0,
936                field_offset!(EllipseInstance, xywh),
937                wgpu::VertexFormat::Float32x4,
938            ),
939            (
940                1,
941                field_offset!(EllipseInstance, color0),
942                wgpu::VertexFormat::Float32x4,
943            ),
944            (
945                2,
946                field_offset!(EllipseInstance, color1),
947                wgpu::VertexFormat::Float32x4,
948            ),
949            (
950                3,
951                field_offset!(EllipseInstance, fwd_mat),
952                wgpu::VertexFormat::Float32x4,
953            ),
954            (
955                4,
956                field_offset!(EllipseInstance, grad),
957                wgpu::VertexFormat::Float32x4,
958            ),
959            (
960                5,
961                field_offset!(EllipseInstance, bits),
962                wgpu::VertexFormat::Uint32,
963            ),
964        ]);
965        let ellipse_border_attrs = vattrs(&[
966            (
967                0,
968                field_offset!(EllipseBorderInstance, xywh),
969                wgpu::VertexFormat::Float32x4,
970            ),
971            (
972                1,
973                field_offset!(EllipseBorderInstance, color0),
974                wgpu::VertexFormat::Float32x4,
975            ),
976            (
977                2,
978                field_offset!(EllipseBorderInstance, color1),
979                wgpu::VertexFormat::Float32x4,
980            ),
981            (
982                3,
983                field_offset!(EllipseBorderInstance, fwd_mat),
984                wgpu::VertexFormat::Float32x4,
985            ),
986            (
987                4,
988                field_offset!(EllipseBorderInstance, grad),
989                wgpu::VertexFormat::Float32x4,
990            ),
991            (
992                5,
993                field_offset!(EllipseBorderInstance, bits),
994                wgpu::VertexFormat::Uint32,
995            ),
996            (
997                6,
998                field_offset!(EllipseBorderInstance, stroke),
999                wgpu::VertexFormat::Float32,
1000            ),
1001            (
1002                7,
1003                field_offset!(EllipseBorderInstance, pad),
1004                wgpu::VertexFormat::Float32,
1005            ),
1006        ]);
1007
1008        make_content_pipeline!(rects, "rect", RectInstance, &rect_attrs);
1009        make_content_pipeline!(borders, "border", BorderInstance, &border_attrs);
1010        make_content_pipeline!(ellipses, "ellipse", EllipseInstance, &ellipse_attrs);
1011        make_content_pipeline!(
1012            ellipse_borders,
1013            "ellipse_border",
1014            EllipseBorderInstance,
1015            &ellipse_border_attrs
1016        );
1017
1018        let arc_attrs = vattrs(&[
1019            (
1020                0,
1021                field_offset!(ArcInstance, xywh),
1022                wgpu::VertexFormat::Float32x4,
1023            ),
1024            (
1025                1,
1026                field_offset!(ArcInstance, color0),
1027                wgpu::VertexFormat::Float32x4,
1028            ),
1029            (
1030                2,
1031                field_offset!(ArcInstance, color1),
1032                wgpu::VertexFormat::Float32x4,
1033            ),
1034            (
1035                3,
1036                field_offset!(ArcInstance, fwd_mat),
1037                wgpu::VertexFormat::Float32x4,
1038            ),
1039            (
1040                4,
1041                field_offset!(ArcInstance, grad),
1042                wgpu::VertexFormat::Float32x4,
1043            ),
1044            (
1045                5,
1046                field_offset!(ArcInstance, bits),
1047                wgpu::VertexFormat::Uint32,
1048            ),
1049            (
1050                6,
1051                field_offset!(ArcInstance, start_angle),
1052                wgpu::VertexFormat::Float32,
1053            ),
1054            (
1055                7,
1056                field_offset!(ArcInstance, sweep_angle),
1057                wgpu::VertexFormat::Float32,
1058            ),
1059            (
1060                8,
1061                field_offset!(ArcInstance, stroke),
1062                wgpu::VertexFormat::Float32,
1063            ),
1064            (
1065                9,
1066                field_offset!(ArcInstance, pad),
1067                wgpu::VertexFormat::Float32,
1068            ),
1069        ]);
1070
1071        make_content_pipeline!(arcs, "arc", ArcInstance, &arc_attrs);
1072
1073        // Text (mask)
1074        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1075            label: Some("text.wgsl"),
1076            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
1077        });
1078        // Text (color)
1079        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1080            label: Some("text_color.wgsl"),
1081            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1082                "shaders/text_color.wgsl"
1083            ))),
1084        });
1085        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1086            label: Some("text pipeline layout"),
1087            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1088            immediate_size: 0,
1089        });
1090        let glyph_vertex = wgpu::VertexBufferLayout {
1091            array_stride: std::mem::size_of::<GlyphInstance>() as u64,
1092            step_mode: wgpu::VertexStepMode::Instance,
1093            attributes: &[
1094                wgpu::VertexAttribute {
1095                    shader_location: 0,
1096                    offset: 0,
1097                    format: wgpu::VertexFormat::Float32x4,
1098                },
1099                wgpu::VertexAttribute {
1100                    shader_location: 1,
1101                    offset: 16,
1102                    format: wgpu::VertexFormat::Float32x4,
1103                },
1104                wgpu::VertexAttribute {
1105                    shader_location: 2,
1106                    offset: 32,
1107                    format: wgpu::VertexFormat::Float32x4,
1108                },
1109                wgpu::VertexAttribute {
1110                    shader_location: 3,
1111                    offset: 48,
1112                    format: wgpu::VertexFormat::Float32x4,
1113                },
1114            ],
1115        };
1116        let text_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1117            label: Some("text pipeline (mask)"),
1118            layout: Some(&text_pipeline_layout),
1119            vertex: wgpu::VertexState {
1120                module: &text_mask_shader,
1121                entry_point: Some("vs_main"),
1122                buffers: &[Some(glyph_vertex.clone())],
1123                compilation_options: wgpu::PipelineCompilationOptions::default(),
1124            },
1125            fragment: Some(wgpu::FragmentState {
1126                module: &text_mask_shader,
1127                entry_point: Some("fs_main"),
1128                targets: &[Some(wgpu::ColorTargetState {
1129                    format,
1130                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1131                    write_mask: wgpu::ColorWrites::ALL,
1132                })],
1133                compilation_options: wgpu::PipelineCompilationOptions::default(),
1134            }),
1135            primitive: wgpu::PrimitiveState::default(),
1136            depth_stencil: Some(stencil_for_content.clone()),
1137            multisample: msaa_state,
1138            multiview_mask: None,
1139            cache: None,
1140        });
1141        let text_color_pipeline =
1142            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1143                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1144                    label: Some(label),
1145                    layout: Some(&text_pipeline_layout),
1146                    vertex: wgpu::VertexState {
1147                        module: &text_color_shader,
1148                        entry_point: Some("vs_main"),
1149                        buffers: &[Some(glyph_vertex.clone())],
1150                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1151                    },
1152                    fragment: Some(wgpu::FragmentState {
1153                        module: &text_color_shader,
1154                        entry_point: Some("fs_main"),
1155                        targets: &[Some(wgpu::ColorTargetState {
1156                            format,
1157                            blend: Some(blend),
1158                            write_mask: wgpu::ColorWrites::ALL,
1159                        })],
1160                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1161                    }),
1162                    primitive: wgpu::PrimitiveState::default(),
1163                    depth_stencil: Some(stencil_for_content.clone()),
1164                    multisample: msaa_state,
1165                    multiview_mask: None,
1166                    cache: None,
1167                })
1168            };
1169        let text_color = text_color_pipeline(
1170            "text pipeline (color)",
1171            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1172        );
1173        // image_rgba reuses the text color pipeline (same vertex/bindings).
1174        let image_rgba = text_color.clone();
1175        // Fixed-function blend variants for `ImageBlended`, indexed like
1176        // `FIXED_FUNCTION_BLENDS`.
1177        let image_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1178            std::array::from_fn(|index| {
1179                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1180                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1181                text_color_pipeline("image pipeline (blend)", blend)
1182            });
1183
1184        // Tinted A8 coverage composite. Same vertex attributes (GlyphInstance)
1185        // and bind groups (globals + texture/sampler) as the text color path,
1186        // sampling R8 tiles uploaded via `register_coverage_a8`.
1187        let coverage_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1188            label: Some("coverage.wgsl"),
1189            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/coverage.wgsl"))),
1190        });
1191        let coverage = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1192            label: Some("coverage pipeline (tinted a8)"),
1193            layout: Some(&text_pipeline_layout),
1194            vertex: wgpu::VertexState {
1195                module: &coverage_shader,
1196                entry_point: Some("vs_main"),
1197                buffers: &[Some(glyph_vertex.clone())],
1198                compilation_options: wgpu::PipelineCompilationOptions::default(),
1199            },
1200            fragment: Some(wgpu::FragmentState {
1201                module: &coverage_shader,
1202                entry_point: Some("fs_main"),
1203                targets: &[Some(wgpu::ColorTargetState {
1204                    format,
1205                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1206                    write_mask: wgpu::ColorWrites::ALL,
1207                })],
1208                compilation_options: wgpu::PipelineCompilationOptions::default(),
1209            }),
1210            primitive: wgpu::PrimitiveState::default(),
1211            depth_stencil: Some(stencil_for_content.clone()),
1212            multisample: msaa_state,
1213            multiview_mask: None,
1214            cache: None,
1215        });
1216
1217        let blur_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1218            label: Some("blur_separable.wgsl"),
1219            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1220                "shaders/blur_separable.wgsl"
1221            ))),
1222        });
1223        let blur_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1224            label: Some("blur pipeline layout"),
1225            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1226            immediate_size: 0,
1227        });
1228        let blur_layout = wgpu::VertexBufferLayout {
1229            array_stride: std::mem::size_of::<BlurInstance>() as u64,
1230            step_mode: wgpu::VertexStepMode::Instance,
1231            attributes: &[
1232                wgpu::VertexAttribute {
1233                    shader_location: 0,
1234                    offset: 0,
1235                    format: wgpu::VertexFormat::Float32x4,
1236                },
1237                wgpu::VertexAttribute {
1238                    shader_location: 1,
1239                    offset: 16,
1240                    format: wgpu::VertexFormat::Float32x4,
1241                },
1242                wgpu::VertexAttribute {
1243                    shader_location: 2,
1244                    offset: 32,
1245                    format: wgpu::VertexFormat::Float32x4,
1246                },
1247                wgpu::VertexAttribute {
1248                    shader_location: 3,
1249                    offset: 48,
1250                    format: wgpu::VertexFormat::Float32x2,
1251                },
1252                wgpu::VertexAttribute {
1253                    shader_location: 4,
1254                    offset: 56,
1255                    format: wgpu::VertexFormat::Float32x4,
1256                },
1257                wgpu::VertexAttribute {
1258                    shader_location: 5,
1259                    offset: 72,
1260                    format: wgpu::VertexFormat::Uint32,
1261                },
1262                wgpu::VertexAttribute {
1263                    shader_location: 6,
1264                    offset: 76,
1265                    format: wgpu::VertexFormat::Uint32,
1266                },
1267                wgpu::VertexAttribute {
1268                    shader_location: 7,
1269                    offset: 80,
1270                    format: wgpu::VertexFormat::Float32x4,
1271                },
1272            ],
1273        };
1274        let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1275            label: Some("blur pipeline"),
1276            layout: Some(&blur_pipeline_layout),
1277            vertex: wgpu::VertexState {
1278                module: &blur_shader,
1279                entry_point: Some("vs_main"),
1280                buffers: &[Some(blur_layout.clone())],
1281                compilation_options: wgpu::PipelineCompilationOptions::default(),
1282            },
1283            fragment: Some(wgpu::FragmentState {
1284                module: &blur_shader,
1285                entry_point: Some("fs_alpha"),
1286                targets: &[Some(wgpu::ColorTargetState {
1287                    format,
1288                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1289                    write_mask: wgpu::ColorWrites::ALL,
1290                })],
1291                compilation_options: wgpu::PipelineCompilationOptions::default(),
1292            }),
1293            primitive: wgpu::PrimitiveState::default(),
1294            depth_stencil: Some(stencil_for_content.clone()),
1295            multisample: msaa_state,
1296            multiview_mask: None,
1297            cache: None,
1298        });
1299
1300        let blur_content = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1301            label: Some("blur content pipeline"),
1302            layout: Some(&blur_pipeline_layout),
1303            vertex: wgpu::VertexState {
1304                module: &blur_shader,
1305                entry_point: Some("vs_main"),
1306                buffers: &[Some(blur_layout)],
1307                compilation_options: wgpu::PipelineCompilationOptions::default(),
1308            },
1309            fragment: Some(wgpu::FragmentState {
1310                module: &blur_shader,
1311                entry_point: Some("fs_color"),
1312                targets: &[Some(wgpu::ColorTargetState {
1313                    format,
1314                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1315                    write_mask: wgpu::ColorWrites::ALL,
1316                })],
1317                compilation_options: wgpu::PipelineCompilationOptions::default(),
1318            }),
1319            primitive: wgpu::PrimitiveState::default(),
1320            depth_stencil: Some(stencil_for_content.clone()),
1321            multisample: msaa_state,
1322            multiview_mask: None,
1323            cache: None,
1324        });
1325
1326        // NV12 Image Pipeline
1327        let image_nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1328            label: Some("image_nv12.wgsl"),
1329            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1330                "shaders/image_nv12.wgsl"
1331            ))),
1332        });
1333        let image_nv12_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1334            label: Some("image nv12 pipeline layout"),
1335            bind_group_layouts: &[Some(globals_layout), Some(image_bind_layout_nv12)],
1336            immediate_size: 0,
1337        });
1338        let nv12_vertex_layout = wgpu::VertexBufferLayout {
1339            array_stride: std::mem::size_of::<Nv12Instance>() as u64,
1340            step_mode: wgpu::VertexStepMode::Instance,
1341            attributes: &[
1342                wgpu::VertexAttribute {
1343                    shader_location: 0,
1344                    offset: 0,
1345                    format: wgpu::VertexFormat::Float32x4,
1346                },
1347                wgpu::VertexAttribute {
1348                    shader_location: 1,
1349                    offset: 16,
1350                    format: wgpu::VertexFormat::Float32x4,
1351                },
1352                wgpu::VertexAttribute {
1353                    shader_location: 2,
1354                    offset: 32,
1355                    format: wgpu::VertexFormat::Float32x4,
1356                },
1357                wgpu::VertexAttribute {
1358                    shader_location: 3,
1359                    offset: 48,
1360                    format: wgpu::VertexFormat::Float32,
1361                },
1362                wgpu::VertexAttribute {
1363                    shader_location: 4,
1364                    offset: 52,
1365                    format: wgpu::VertexFormat::Float32,
1366                },
1367                wgpu::VertexAttribute {
1368                    shader_location: 5,
1369                    offset: 56,
1370                    format: wgpu::VertexFormat::Float32x4,
1371                },
1372            ],
1373        };
1374        let nv12_pipeline =
1375            |label: &'static str, blend: wgpu::BlendState| -> wgpu::RenderPipeline {
1376                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1377                    label: Some(label),
1378                    layout: Some(&image_nv12_layout),
1379                    vertex: wgpu::VertexState {
1380                        module: &image_nv12_shader,
1381                        entry_point: Some("vs_main"),
1382                        buffers: &[Some(nv12_vertex_layout.clone())],
1383                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1384                    },
1385                    fragment: Some(wgpu::FragmentState {
1386                        module: &image_nv12_shader,
1387                        entry_point: Some("fs_main"),
1388                        targets: &[Some(wgpu::ColorTargetState {
1389                            format,
1390                            blend: Some(blend),
1391                            write_mask: wgpu::ColorWrites::ALL,
1392                        })],
1393                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1394                    }),
1395                    primitive: wgpu::PrimitiveState::default(),
1396                    depth_stencil: Some(stencil_for_content.clone()),
1397                    multisample: msaa_state,
1398                    multiview_mask: None,
1399                    cache: None,
1400                })
1401            };
1402        let image_nv12 = nv12_pipeline(
1403            "image nv12 pipeline",
1404            wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING,
1405        );
1406        let image_nv12_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1407            std::array::from_fn(|index| {
1408                let blend = fixed_function_blend(FIXED_FUNCTION_BLENDS[index])
1409                    .unwrap_or(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1410                nv12_pipeline("image nv12 pipeline (blend)", blend)
1411            });
1412
1413        let clip_color_target = wgpu::ColorTargetState {
1414            format,
1415            blend: None,
1416            write_mask: wgpu::ColorWrites::empty(),
1417        };
1418
1419        // Clipping
1420        let clip_shader_bin = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1421            label: Some("clip_round_rect_bin.wgsl"),
1422            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1423                "shaders/clip_round_rect_bin.wgsl"
1424            ))),
1425        });
1426        let clip_bin = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1427            label: Some("clip pipeline (bin)"),
1428            layout: Some(clip_pipeline_layout),
1429            vertex: wgpu::VertexState {
1430                module: &clip_shader_bin,
1431                entry_point: Some("vs_main"),
1432                buffers: &[Some(clip_vertex_layout.clone())],
1433                compilation_options: wgpu::PipelineCompilationOptions::default(),
1434            },
1435            fragment: Some(wgpu::FragmentState {
1436                module: &clip_shader_bin,
1437                entry_point: Some("fs_main"),
1438                targets: &[Some(clip_color_target.clone())],
1439                compilation_options: wgpu::PipelineCompilationOptions::default(),
1440            }),
1441            primitive: wgpu::PrimitiveState::default(),
1442            depth_stencil: Some(stencil_for_clip_inc.clone()),
1443            multisample: wgpu::MultisampleState {
1444                count: sample_count,
1445                mask: !0,
1446                alpha_to_coverage_enabled: false,
1447            },
1448            multiview_mask: None,
1449            cache: None,
1450        });
1451        let clip_dec = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1452            label: Some("clip pipeline (dec)"),
1453            layout: Some(clip_pipeline_layout),
1454            vertex: wgpu::VertexState {
1455                module: &clip_shader_bin,
1456                entry_point: Some("vs_main"),
1457                buffers: &[Some(clip_vertex_layout.clone())],
1458                compilation_options: wgpu::PipelineCompilationOptions::default(),
1459            },
1460            fragment: Some(wgpu::FragmentState {
1461                module: &clip_shader_bin,
1462                entry_point: Some("fs_main"),
1463                targets: &[Some(clip_color_target.clone())],
1464                compilation_options: wgpu::PipelineCompilationOptions::default(),
1465            }),
1466            primitive: wgpu::PrimitiveState::default(),
1467            depth_stencil: Some(stencil_for_clip_dec.clone()),
1468            multisample: wgpu::MultisampleState {
1469                count: sample_count,
1470                mask: !0,
1471                alpha_to_coverage_enabled: false,
1472            },
1473            multiview_mask: None,
1474            cache: None,
1475        });
1476
1477        let slug = Some(slug::create_pipeline(
1478            device,
1479            format,
1480            sample_count,
1481            stencil_for_content,
1482        ));
1483
1484        // Tessellated vector mesh pipeline (host-supplied vertex/index data).
1485        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1486            label: Some("mesh.wgsl"),
1487            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/mesh.wgsl"))),
1488        });
1489        let mesh_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1490            label: Some("mesh pipeline layout"),
1491            bind_group_layouts: &[Some(globals_layout), Some(mesh_bind_layout)],
1492            immediate_size: 0,
1493        });
1494        let mesh_vertex_layout = wgpu::VertexBufferLayout {
1495            array_stride: std::mem::size_of::<MeshVertex>() as u64,
1496            step_mode: wgpu::VertexStepMode::Vertex,
1497            attributes: &[
1498                wgpu::VertexAttribute {
1499                    shader_location: 0,
1500                    offset: 0,
1501                    format: wgpu::VertexFormat::Float32x2,
1502                },
1503                wgpu::VertexAttribute {
1504                    shader_location: 1,
1505                    offset: 8,
1506                    format: wgpu::VertexFormat::Float32x4,
1507                },
1508                wgpu::VertexAttribute {
1509                    shader_location: 2,
1510                    offset: 24,
1511                    format: wgpu::VertexFormat::Float32x2,
1512                },
1513            ],
1514        };
1515        let mesh_instance_layout = wgpu::VertexBufferLayout {
1516            array_stride: std::mem::size_of::<MeshInstance>() as u64,
1517            step_mode: wgpu::VertexStepMode::Instance,
1518            attributes: &[
1519                wgpu::VertexAttribute {
1520                    shader_location: 3,
1521                    offset: 0,
1522                    format: wgpu::VertexFormat::Float32x4,
1523                },
1524                wgpu::VertexAttribute {
1525                    shader_location: 4,
1526                    offset: 16,
1527                    format: wgpu::VertexFormat::Float32x4,
1528                },
1529            ],
1530        };
1531        let make_mesh_pipeline =
1532            |label: &str, depth: &wgpu::DepthStencilState, color: &wgpu::ColorTargetState| {
1533                device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1534                    label: Some(label),
1535                    layout: Some(&mesh_pipeline_layout),
1536                    vertex: wgpu::VertexState {
1537                        module: &mesh_shader,
1538                        entry_point: Some("vs_main"),
1539                        buffers: &[
1540                            Some(mesh_vertex_layout.clone()),
1541                            Some(mesh_instance_layout.clone()),
1542                        ],
1543                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1544                    },
1545                    fragment: Some(wgpu::FragmentState {
1546                        module: &mesh_shader,
1547                        entry_point: Some("fs_main"),
1548                        targets: &[Some(color.clone())],
1549                        compilation_options: wgpu::PipelineCompilationOptions::default(),
1550                    }),
1551                    primitive: wgpu::PrimitiveState {
1552                        topology: wgpu::PrimitiveTopology::TriangleList,
1553                        ..Default::default()
1554                    },
1555                    depth_stencil: Some(depth.clone()),
1556                    multisample: msaa_state,
1557                    multiview_mask: None,
1558                    cache: None,
1559                })
1560            };
1561        let mesh_color_target = wgpu::ColorTargetState {
1562            format,
1563            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1564            write_mask: wgpu::ColorWrites::ALL,
1565        };
1566        let mut stencil_for_mesh = stencil_for_content.clone();
1567        stencil_for_mesh.stencil.front.compare = wgpu::CompareFunction::Equal;
1568        stencil_for_mesh.stencil.back.compare = wgpu::CompareFunction::Equal;
1569        let mut stencil_for_overlay = stencil_for_content.clone();
1570        stencil_for_overlay.stencil.front.compare = wgpu::CompareFunction::LessEqual;
1571        stencil_for_overlay.stencil.back.compare = wgpu::CompareFunction::LessEqual;
1572        let mesh = make_mesh_pipeline("mesh pipeline", &stencil_for_mesh, &mesh_color_target);
1573        let mesh_overlay = make_mesh_pipeline(
1574            "mesh overlay pipeline",
1575            &stencil_for_overlay,
1576            &mesh_color_target,
1577        );
1578        let mesh_clip_inc = make_mesh_pipeline(
1579            "mesh clip (inc) pipeline",
1580            stencil_for_clip_inc,
1581            &clip_color_target,
1582        );
1583        let mesh_clip_dec = make_mesh_pipeline(
1584            "mesh clip (dec) pipeline",
1585            stencil_for_clip_dec,
1586            &clip_color_target,
1587        );
1588        let mesh_blend_target = |blend: wgpu::BlendState| wgpu::ColorTargetState {
1589            format,
1590            blend: Some(blend),
1591            write_mask: wgpu::ColorWrites::ALL,
1592        };
1593        let mesh_blends: [wgpu::RenderPipeline; FIXED_FUNCTION_BLENDS.len()] =
1594            std::array::from_fn(|index| {
1595                let mode = FIXED_FUNCTION_BLENDS[index];
1596                let blend = fixed_function_blend(mode).unwrap_or(wgpu::BlendState::REPLACE);
1597                make_mesh_pipeline(
1598                    match mode {
1599                        repose_core::BlendMode::Add => "mesh pipeline (add)",
1600                        repose_core::BlendMode::Multiply => "mesh pipeline (multiply)",
1601                        repose_core::BlendMode::Screen => "mesh pipeline (screen)",
1602                        repose_core::BlendMode::Darken => "mesh pipeline (darken)",
1603                        _ => "mesh pipeline (lighten)",
1604                    },
1605                    &stencil_for_mesh,
1606                    &mesh_blend_target(blend),
1607                )
1608            });
1609        // Projective layer composite (perspective flattening). Same
1610        // bind groups as the text/image path (globals + layer texture), with
1611        // per-instance projected corners. Like `image_rgba` it draws into the
1612        // parent target, so it shares the content stencil state.
1613        let projective_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1614            label: Some("projective_layer.wgsl"),
1615            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1616                "shaders/projective_layer.wgsl"
1617            ))),
1618        });
1619        let projective_pipeline_layout =
1620            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1621                label: Some("projective layer pipeline layout"),
1622                bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
1623                immediate_size: 0,
1624            });
1625        let projective_vertex_layout = wgpu::VertexBufferLayout {
1626            array_stride: std::mem::size_of::<ProjectiveInstance>() as u64,
1627            step_mode: wgpu::VertexStepMode::Instance,
1628            attributes: &[
1629                wgpu::VertexAttribute {
1630                    shader_location: 0,
1631                    offset: 0,
1632                    format: wgpu::VertexFormat::Float32x2,
1633                },
1634                wgpu::VertexAttribute {
1635                    shader_location: 1,
1636                    offset: 8,
1637                    format: wgpu::VertexFormat::Float32x2,
1638                },
1639                wgpu::VertexAttribute {
1640                    shader_location: 2,
1641                    offset: 16,
1642                    format: wgpu::VertexFormat::Float32x2,
1643                },
1644                wgpu::VertexAttribute {
1645                    shader_location: 3,
1646                    offset: 24,
1647                    format: wgpu::VertexFormat::Float32x2,
1648                },
1649                wgpu::VertexAttribute {
1650                    shader_location: 4,
1651                    offset: 32,
1652                    format: wgpu::VertexFormat::Float32x4,
1653                },
1654                wgpu::VertexAttribute {
1655                    shader_location: 5,
1656                    offset: 48,
1657                    format: wgpu::VertexFormat::Float32x4,
1658                },
1659                wgpu::VertexAttribute {
1660                    shader_location: 6,
1661                    offset: 64,
1662                    format: wgpu::VertexFormat::Float32,
1663                },
1664            ],
1665        };
1666        let projective_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1667            label: Some("projective layer composite pipeline"),
1668            layout: Some(&projective_pipeline_layout),
1669            vertex: wgpu::VertexState {
1670                module: &projective_shader,
1671                entry_point: Some("vs_main"),
1672                buffers: &[Some(projective_vertex_layout)],
1673                compilation_options: wgpu::PipelineCompilationOptions::default(),
1674            },
1675            fragment: Some(wgpu::FragmentState {
1676                module: &projective_shader,
1677                entry_point: Some("fs_main"),
1678                targets: &[Some(wgpu::ColorTargetState {
1679                    format,
1680                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1681                    write_mask: wgpu::ColorWrites::ALL,
1682                })],
1683                compilation_options: wgpu::PipelineCompilationOptions::default(),
1684            }),
1685            primitive: wgpu::PrimitiveState::default(),
1686            depth_stencil: Some(stencil_for_content.clone()),
1687            multisample: msaa_state,
1688            multiview_mask: None,
1689            cache: None,
1690        });
1691
1692        let blend_layer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1693            label: Some("blend_layer.wgsl"),
1694            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
1695                "shaders/blend_layer.wgsl"
1696            ))),
1697        });
1698        let blend_layer_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1699            label: Some("blend layer pipeline layout"),
1700            bind_group_layouts: &[
1701                Some(globals_layout),
1702                Some(text_bind_layout),
1703                Some(text_bind_layout),
1704            ],
1705            immediate_size: 0,
1706        });
1707        let blend_vertex_layout = wgpu::VertexBufferLayout {
1708            array_stride: std::mem::size_of::<BlendInstance>() as u64,
1709            step_mode: wgpu::VertexStepMode::Instance,
1710            attributes: &[
1711                wgpu::VertexAttribute {
1712                    shader_location: 0,
1713                    offset: 0,
1714                    format: wgpu::VertexFormat::Float32x4,
1715                },
1716                wgpu::VertexAttribute {
1717                    shader_location: 1,
1718                    offset: 16,
1719                    format: wgpu::VertexFormat::Float32x4,
1720                },
1721                wgpu::VertexAttribute {
1722                    shader_location: 2,
1723                    offset: 32,
1724                    format: wgpu::VertexFormat::Float32x4,
1725                },
1726                wgpu::VertexAttribute {
1727                    shader_location: 3,
1728                    offset: 48,
1729                    format: wgpu::VertexFormat::Float32x4,
1730                },
1731                wgpu::VertexAttribute {
1732                    shader_location: 4,
1733                    offset: 64,
1734                    format: wgpu::VertexFormat::Uint32,
1735                },
1736            ],
1737        };
1738        let blend_layer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1739            label: Some("blend layer pipeline"),
1740            layout: Some(&blend_layer_layout),
1741            vertex: wgpu::VertexState {
1742                module: &blend_layer_shader,
1743                entry_point: Some("vs_main"),
1744                buffers: &[Some(blend_vertex_layout)],
1745                compilation_options: wgpu::PipelineCompilationOptions::default(),
1746            },
1747            fragment: Some(wgpu::FragmentState {
1748                module: &blend_layer_shader,
1749                entry_point: Some("fs_main"),
1750                targets: &[Some(wgpu::ColorTargetState {
1751                    format,
1752                    blend: None,
1753                    write_mask: wgpu::ColorWrites::ALL,
1754                })],
1755                compilation_options: wgpu::PipelineCompilationOptions::default(),
1756            }),
1757            primitive: wgpu::PrimitiveState::default(),
1758            depth_stencil: Some(stencil_for_content.clone()),
1759            multisample: msaa_state,
1760            multiview_mask: None,
1761            cache: None,
1762        });
1763
1764        Self {
1765            rects,
1766            borders,
1767            ellipses,
1768            ellipse_borders,
1769            arcs,
1770            text_mask,
1771            text_color,
1772            image_rgba,
1773            image_blends,
1774            image_nv12,
1775            image_nv12_blends,
1776            coverage,
1777            blur,
1778            blur_content,
1779            clip_bin,
1780            clip_dec,
1781            slug,
1782            mesh,
1783            mesh_blends,
1784            blend_layer,
1785            mesh_overlay,
1786            mesh_clip_inc,
1787            mesh_clip_dec,
1788            projective_layer,
1789        }
1790    }
1791}
1792
1793/// AABB of a rect under the *plain affine* part of a transform (linear +
1794/// translation, no origin re-pivot).
1795fn affine_aabb(transform: &Transform, rect: &repose_core::Rect) -> repose_core::Rect {
1796    let m = transform.linear();
1797    let (tx, ty) = (transform.translate_x, transform.translate_y);
1798    let corners = [
1799        (rect.x, rect.y),
1800        (rect.x + rect.w, rect.y),
1801        (rect.x, rect.y + rect.h),
1802        (rect.x + rect.w, rect.y + rect.h),
1803    ];
1804    let mut min_x = f32::INFINITY;
1805    let mut min_y = f32::INFINITY;
1806    let mut max_x = f32::NEG_INFINITY;
1807    let mut max_y = f32::NEG_INFINITY;
1808    for (x, y) in corners {
1809        let wx = m[0] * x + m[1] * y + tx;
1810        let wy = m[2] * x + m[3] * y + ty;
1811        min_x = min_x.min(wx);
1812        min_y = min_y.min(wy);
1813        max_x = max_x.max(wx);
1814        max_y = max_y.max(wy);
1815    }
1816    repose_core::Rect {
1817        x: min_x,
1818        y: min_y,
1819        w: (max_x - min_x).max(0.0),
1820        h: (max_y - min_y).max(0.0),
1821    }
1822}
1823
1824/// Convert a local-space rect + transform to NDC center-based position+size
1825/// plus the forward rotation/shear 2x2 (row-major `[m00, m01, m10, m11]`,
1826/// scale-free: scale rides in the NDC size). Shaders apply it to quad corners
1827/// and its adjugate/determinant inverse to sample positions.
1828fn rect_to_instance_ndc(
1829    rect: repose_core::Rect,
1830    transform: &Transform,
1831    fb_w: f32,
1832    fb_h: f32,
1833) -> ([f32; 4], [f32; 4]) {
1834    let cx = rect.x + rect.w * 0.5;
1835    let cy = rect.y + rect.h * 0.5;
1836
1837    let m = transform.linear();
1838    let tx = m[0] * cx + m[1] * cy + transform.translate_x;
1839    let ty = m[2] * cx + m[3] * cy + transform.translate_y;
1840
1841    let ndc_cx = (tx / fb_w) * 2.0 - 1.0;
1842    let ndc_cy = 1.0 - (ty / fb_h) * 2.0;
1843    // NDC size (after scale only, no rotation - rotation is done in shader)
1844    let ndc_w = (rect.w * transform.scale_x / fb_w) * 2.0;
1845    let ndc_h = (rect.h * transform.scale_y / fb_h) * 2.0;
1846
1847    ([ndc_cx, ndc_cy, ndc_w, ndc_h], forward_rs_mat(transform))
1848}
1849
1850/// Forward rotation+shear 2x2 (row-major, scale-free) for instance
1851/// attributes. Identity for untransformed content; degenerate shear (only
1852/// from absurd inputs) falls back to identity.
1853fn forward_rs_mat(transform: &Transform) -> [f32; 4] {
1854    let c = transform.rotate.cos();
1855    let s = transform.rotate.sin();
1856    let (hx, hy) = (transform.shear_x, transform.shear_y);
1857    let m = [c - s * hy, c * hx - s, s + c * hy, s * hx + c];
1858    if (m[0] * m[3] - m[1] * m[2]).abs() < 1e-6 {
1859        return [1.0, 0.0, 0.0, 1.0];
1860    }
1861    m
1862}
1863
1864/// A segment of the frame that draws into a single render target.
1865struct Pass {
1866    id: u64,
1867    target: PassTarget,
1868    /// The scissor to apply when the pass is opened.
1869    initial_scissor: (u32, u32, u32, u32),
1870    /// The currently active scissor in the translated pass. `None` means an
1871    /// empty intersection and all draws must be suppressed.
1872    active_scissor: Option<(u32, u32, u32, u32)>,
1873    /// `None` means `LoadOp::Load` (resume existing content);
1874    /// `Some(c)` means `LoadOp::Clear(c)`.
1875    clear_color: Option<[f32; 4]>,
1876    cmds: Vec<Cmd>,
1877}
1878
1879struct LayerState {
1880    layer_id: u32,
1881    parent_target: PassTarget,
1882    parent_scissors: Vec<repose_core::Rect>,
1883    parent_root_clip: repose_core::Rect,
1884    parent_size: (f32, f32),
1885    parent_transform: Transform,
1886    parent_scissor: Option<(u32, u32, u32, u32)>,
1887    parent_clips: Vec<ActiveClip>,
1888    alpha: f32,
1889    blur: (f32, f32),
1890    rectangle_edge: bool,
1891}
1892
1893/// One translator-flattened perspective layer (see
1894/// `push_perspective_layer`): the projective map and everything needed to
1895/// restore the parent target on the matching pop.
1896struct FlattenRecord {
1897    /// `transform_stack.len()` before this flatten pushed its two entries
1898    /// (stripped affine + layer-local shift).
1899    stack_len: usize,
1900    layer_id: u32,
1901    /// Full projective map (row-major 3x3): affine ancestors over the
1902    /// perspective node, in parent-target coordinates.
1903    map: [f32; 9],
1904    /// Layer rect in the parent target's coordinates.
1905    layer_rect: repose_core::Rect,
1906    saved_scissor_stack: Vec<repose_core::Rect>,
1907    saved_root: repose_core::Rect,
1908    saved_size: (f32, f32),
1909    saved_active_scissor: Option<(u32, u32, u32, u32)>,
1910    saved_clips: Vec<ActiveClip>,
1911}
1912
1913/// First translator-owned flatten layer id. Producer ids start at 1 per
1914/// scene, so this range never collides; ids are drained from the layer pool
1915/// after each frame, so reuse across frames is safe.
1916const FLATTEN_ID_BASE: u32 = 0xF000_0000;
1917const MAX_GRAPHICS_LAYERS: usize = 128;
1918const MAX_GRAPHICS_LAYER_BYTES: u64 = 256 * 1024 * 1024;
1919const MAX_TRANSIENT_GRAPHICS_LAYERS: usize = 64;
1920const MAX_TRANSIENT_GRAPHICS_LAYER_BYTES: u64 = 128 * 1024 * 1024;
1921const STENCIL_BASE: u32 = 128;
1922const STENCIL_MAX_DEPTH: u32 = 127;
1923
1924#[allow(non_snake_case)]
1925enum Cmd {
1926    ClipPush {
1927        off: u64,
1928        cnt: u32,
1929        scissor: (u32, u32, u32, u32),
1930        difference: bool,
1931        applied: bool,
1932    },
1933    ClipPop {
1934        off: u64,
1935        cnt: u32,
1936        /// Scissor restored for the draws that follow the pop (the parent clip).
1937        scissor: (u32, u32, u32, u32),
1938        /// Scissor the matching push drew its stencil pass with; the decrement
1939        /// must use it so both passes touch the same pixels.
1940        stencil_scissor: (u32, u32, u32, u32),
1941        difference: bool,
1942        applied: bool,
1943    },
1944    Rect {
1945        off: u64,
1946        cnt: u32,
1947    },
1948    Border {
1949        off: u64,
1950        cnt: u32,
1951    },
1952    Ellipse {
1953        off: u64,
1954        cnt: u32,
1955    },
1956    EllipseBorder {
1957        off: u64,
1958        cnt: u32,
1959    },
1960    Arc {
1961        off: u64,
1962        cnt: u32,
1963    },
1964    GlyphsMask {
1965        off: u64,
1966        cnt: u32,
1967    },
1968    GlyphsColor {
1969        off: u64,
1970        cnt: u32,
1971    },
1972    GlyphsVector {
1973        off: u64,
1974        cnt: u32,
1975    },
1976    ImageRgba {
1977        off: u64,
1978        cnt: u32,
1979        handle: u64,
1980        filter: ImageFilter,
1981        /// Bind to the repeating samplers so `ImageFit::Tile` UVs wrap.
1982        tile: bool,
1983        /// Fixed-function blend mode; `Alpha` uses `image_rgba`.
1984        blend: repose_core::BlendMode,
1985    },
1986    /// Composite a tinted A8 coverage tile (`SceneNode::Coverage`). The
1987    /// instance lives in `self.glyph_color.ring` (a `GlyphInstance`); the
1988    /// bind comes from the coverage registry.
1989    Coverage {
1990        off: u64,
1991        cnt: u32,
1992        handle: u64,
1993    },
1994    ImageNv12 {
1995        off: u64,
1996        cnt: u32,
1997        handle: u64,
1998        filter: ImageFilter,
1999        /// Fixed-function blend mode; `Alpha` uses `image_nv12`.
2000        blend: repose_core::BlendMode,
2001    },
2002    /// Composite a previously-rendered graphics layer back into the
2003    /// current target as a textured quad. The quad's vertex buffer
2004    /// lives in `self.glyph_color.ring` (a `GlyphInstance`).
2005    CompositeLayer {
2006        off: u64,
2007        cnt: u32,
2008        layer_id: u32,
2009    },
2010    /// Separable gaussian blur, alpha channel only: the horizontal pass into a
2011    /// scratch layer, and the vertical pass that tints the result as a drop
2012    /// shadow. `layer_id` names the texture to sample, which is the source
2013    /// layer for the first pass and the scratch for the second. The quad's
2014    /// vertex buffer lives in `self.blur_ring` (a `BlurInstance`).
2015    CompositeShadow {
2016        off: u64,
2017        cnt: u32,
2018        layer_id: u32,
2019    },
2020    /// Separable gaussian blur over full RGBA, for `Modifier::blur`. Two
2021    /// stages exactly like [`Cmd::CompositeShadow`].
2022    CompositeBlur {
2023        off: u64,
2024        cnt: u32,
2025        layer_id: u32,
2026    },
2027    /// Composite a flattened perspective layer through its projective map.
2028    /// The instance lives in `self.projective_ring` (a `ProjectiveInstance`
2029    /// with CPU-projected NDC corners); sampled from the layer's texture
2030    /// with perspective-correct UVs by the `projective_layer` pipeline.
2031    CompositeProjective {
2032        off: u64,
2033        cnt: u32,
2034        layer_id: u32,
2035    },
2036    /// Draw a tessellated vector mesh (solid or gradient paint).
2037    VectorMesh {
2038        mesh: MeshGpu,
2039        uoff: u64,
2040        /// Byte offset of this draw's placement transforms in
2041        /// `mesh_instances`, and how many of them to draw.
2042        ioff: u64,
2043        icnt: u32,
2044        blend: repose_core::BlendMode,
2045    },
2046    /// Composite an isolated source layer over the current target with a
2047    /// backdrop-dependent CSS blend mode. The shader samples both textures
2048    /// and composites source-over by hand, so the pass runs REPLACE.
2049    /// `parent` records which target the composite draws into (surface or
2050    /// layer); the instance NDC is mapped against it, so any parent origin
2051    /// works.
2052    BlendLayer {
2053        off: u64,
2054        cnt: u32,
2055        src_layer: u32,
2056        dst_layer: Option<u32>,
2057        parent: PassTarget,
2058        scissor: (u32, u32, u32, u32),
2059    },
2060    /// Draw a screen-space overlay mesh (identity transform, device pixels).
2061    VectorOverlay {
2062        mesh: MeshGpu,
2063        uoff: u64,
2064        ioff: u64,
2065        icnt: u32,
2066    },
2067    /// Increment the stencil buffer with a tessellated vector mask.
2068    /// `difference` marks an inverse (`\iclip`-style) mask: content draws
2069    /// *outside* it. The counting still balances (push increments, pop
2070    /// decrements); only the depth bookkeeping differs (see executor).
2071    VectorClipPush {
2072        mesh: MeshGpu,
2073        uoff: u64,
2074        ioff: u64,
2075        icnt: u32,
2076        scissor: (u32, u32, u32, u32),
2077        difference: bool,
2078        applied: bool,
2079    },
2080    /// Decrement the stencil buffer with the matching vector mask.
2081    VectorClipPop {
2082        mesh: MeshGpu,
2083        uoff: u64,
2084        ioff: u64,
2085        icnt: u32,
2086        scissor: (u32, u32, u32, u32),
2087        stencil_scissor: (u32, u32, u32, u32),
2088        difference: bool,
2089        applied: bool,
2090    },
2091    Callback {
2092        rect: repose_core::Rect,
2093        clip_rect: repose_core::Rect,
2094        scissor: (u32, u32, u32, u32),
2095        restore_scissor: (u32, u32, u32, u32),
2096        callback_id: usize,
2097        payload: repose_core::PaintCallbackPayload,
2098    },
2099}
2100
2101/// A registered A8 coverage tile: single-channel mask sampled as coverage
2102/// by `SceneNode::Coverage`. Tiles are immutable; producers re-register on
2103/// geometry change and `remove_coverage` stale handles (unused tiles also
2104/// age out via the image eviction policy).
2105struct CoverageTex {
2106    // Held to keep the GPU texture alive (freed on remove/evict).
2107    #[allow(dead_code)]
2108    tex: wgpu::Texture,
2109    bind: wgpu::BindGroup,
2110    w: u32,
2111    h: u32,
2112    last_used_frame: u64,
2113    bytes: u64,
2114}
2115
2116struct ImageBinds {
2117    linear: wgpu::BindGroup,
2118    nearest: wgpu::BindGroup,
2119    /// `ImageFit::Tile` variants, bound to repeating samplers.
2120    tile_linear: wgpu::BindGroup,
2121    tile_nearest: wgpu::BindGroup,
2122}
2123
2124impl ImageBinds {
2125    fn get(&self, filter: ImageFilter, tile: bool) -> &wgpu::BindGroup {
2126        match (filter, tile) {
2127            (ImageFilter::Linear, false) => &self.linear,
2128            (ImageFilter::Nearest, false) => &self.nearest,
2129            (ImageFilter::Linear, true) => &self.tile_linear,
2130            (ImageFilter::Nearest, true) => &self.tile_nearest,
2131        }
2132    }
2133}
2134
2135enum ImageTex {
2136    Rgba {
2137        tex: wgpu::Texture,
2138        binds: ImageBinds,
2139        w: u32,
2140        h: u32,
2141        format: wgpu::TextureFormat,
2142        last_used_frame: u64,
2143        bytes: u64,
2144    },
2145    /// For a user-provided texture view.
2146    User {
2147        binds: ImageBinds,
2148        w: u32,
2149        h: u32,
2150        last_used_frame: u64,
2151        bytes: u64,
2152    },
2153    Nv12 {
2154        tex_y: wgpu::Texture,
2155        tex_uv: wgpu::Texture,
2156        binds: ImageBinds,
2157        yuv_buf: wgpu::Buffer,
2158        w: u32,
2159        h: u32,
2160        pixel_format: PixelFormat,
2161        fourcc: Option<u32>,
2162        color_info: ColorInfo,
2163        external: bool,
2164        last_used_frame: u64,
2165        bytes: u64,
2166    },
2167}
2168
2169impl ImageTex {
2170    fn evictable(&self) -> bool {
2171        match self {
2172            Self::Rgba { .. } => true,
2173            Self::User { .. } => false,
2174            Self::Nv12 { external, .. } => !*external,
2175        }
2176    }
2177}
2178
2179#[derive(Clone)]
2180struct RetainedImage {
2181    w: u32,
2182    h: u32,
2183    format: wgpu::TextureFormat,
2184    rgba: Vec<u8>,
2185    last_used_frame: u64,
2186}
2187
2188struct ResolvedImageSource {
2189    width: f32,
2190    height: f32,
2191    uv_min: [f32; 2],
2192    uv_max: [f32; 2],
2193}
2194
2195fn resolve_image_source(
2196    source_rect: Option<ImageSourceRect>,
2197    image_width: u32,
2198    image_height: u32,
2199) -> Option<ResolvedImageSource> {
2200    if image_width == 0 || image_height == 0 {
2201        return None;
2202    }
2203    let Some(source) = source_rect else {
2204        return Some(ResolvedImageSource {
2205            width: image_width as f32,
2206            height: image_height as f32,
2207            uv_min: [0.0, 0.0],
2208            uv_max: [1.0, 1.0],
2209        });
2210    };
2211    if source.is_empty() {
2212        return None;
2213    }
2214    let right = source.x.checked_add(source.width)?;
2215    let bottom = source.y.checked_add(source.height)?;
2216    if right > image_width || bottom > image_height {
2217        return None;
2218    }
2219    Some(ResolvedImageSource {
2220        width: source.width as f32,
2221        height: source.height as f32,
2222        uv_min: [
2223            source.x as f32 / image_width as f32,
2224            source.y as f32 / image_height as f32,
2225        ],
2226        uv_max: [
2227            right as f32 / image_width as f32,
2228            bottom as f32 / image_height as f32,
2229        ],
2230    })
2231}
2232
2233/// Draw rect and UV range for `fit` inside `rect`.
2234///
2235/// Mirrors Compose's `PainterNode.draw`: scale the source by
2236/// `ContentScale.computeScaleFactor`, align the result within the bounds, then
2237/// clip to those bounds. Clipping remaps the UVs rather than pushing a clip
2238/// rect, matching what Compose's `clipToBounds` and Godot's `clip_contents`
2239/// produce visually without the extra pass. Modes that scale past an edge
2240/// (`Cover`, `FitWidth`, `FitHeight`, `None`) rely on it to stay in bounds.
2241fn image_fit_geometry(
2242    rect: repose_core::Rect,
2243    fit: ImageFit,
2244    alignment: ImageAlignment,
2245    source: &ResolvedImageSource,
2246) -> Option<(repose_core::Rect, [f32; 4])> {
2247    let src_w = source.width;
2248    let src_h = source.height;
2249    let dst_w = rect.w.max(0.0);
2250    let dst_h = rect.h.max(0.0);
2251    if src_w <= 0.0 || src_h <= 0.0 || dst_w <= 0.0 || dst_h <= 0.0 {
2252        return None;
2253    }
2254
2255    // Slack placement inside the destination rect: `alignment` picks which
2256    // corner the fitted image hugs (CSS `object-position`).
2257    let slack = |content: f32, extent: f32| match alignment {
2258        ImageAlignment::Center => (extent - content) * 0.5,
2259        ImageAlignment::Begin => 0.0,
2260        ImageAlignment::End => extent - content,
2261    };
2262
2263    // A tiled draw covers the bounds at native resolution, so its UVs run past
2264    // 1.0 and the sampler wraps (see address-mode selection at draw time).
2265    let tiled = matches!(fit, ImageFit::Tile);
2266    let (scale_x, scale_y) = fit.scale_factor((src_w, src_h), (dst_w, dst_h));
2267    let drawn = if tiled {
2268        repose_core::Rect {
2269            x: rect.x,
2270            y: rect.y,
2271            w: dst_w,
2272            h: dst_h,
2273        }
2274    } else {
2275        let (w, h) = (src_w * scale_x, src_h * scale_y);
2276        repose_core::Rect {
2277            x: rect.x + slack(w, dst_w),
2278            y: rect.y + slack(h, dst_h),
2279            w,
2280            h,
2281        }
2282    };
2283
2284    let uv_min = source.uv_min;
2285    let uv_max = source.uv_max;
2286    let repeats = if tiled {
2287        [dst_w / src_w, dst_h / src_h]
2288    } else {
2289        [1.0, 1.0]
2290    };
2291    let mut uv = [
2292        uv_min[0],
2293        uv_max[1],
2294        uv_min[0] + (uv_max[0] - uv_min[0]) * repeats[0],
2295        uv_max[1] - (uv_max[1] - uv_min[1]) * repeats[1],
2296    ];
2297
2298    let clipped = intersect(drawn, rect);
2299    if clipped.w <= 0.0 || clipped.h <= 0.0 {
2300        return None;
2301    }
2302    let du = uv[2] - uv[0];
2303    let dv = uv[3] - uv[1];
2304    uv = [
2305        uv[0] + du * (clipped.x - drawn.x) / drawn.w,
2306        uv[1] + dv * (clipped.y - drawn.y) / drawn.h,
2307        uv[0] + du * (clipped.x + clipped.w - drawn.x) / drawn.w,
2308        uv[1] + dv * (clipped.y + clipped.h - drawn.y) / drawn.h,
2309    ];
2310    Some((clipped, uv))
2311}
2312
2313struct AtlasA8 {
2314    tex: wgpu::Texture,
2315    view: wgpu::TextureView,
2316    sampler: wgpu::Sampler,
2317    size: u32,
2318    next_x: u32,
2319    next_y: u32,
2320    row_h: u32,
2321    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2322    /// Summed `w * h` of every live entry. The pixel budget is what actually
2323    /// binds a shelf packer, so eviction trims on area, not entry count.
2324    used_area: u64,
2325}
2326
2327struct AtlasRGBA {
2328    tex: wgpu::Texture,
2329    view: wgpu::TextureView,
2330    sampler: wgpu::Sampler,
2331    size: u32,
2332    next_x: u32,
2333    next_y: u32,
2334    row_h: u32,
2335    map: HashMap<repose_text::CacheKey, GlyphInfo>,
2336    used_area: u64,
2337}
2338
2339/// Work an atlas could not do yet, applied at the next frame boundary. Both
2340/// growth and compaction re-pack the atlas, which would invalidate UVs already
2341/// batched into the frame in flight, so neither may run mid-frame.
2342#[derive(Default, Clone, Copy)]
2343struct AtlasPending {
2344    /// A cell did not fit and the atlas is still below its size cap.
2345    grow: bool,
2346    /// The atlas is at its cap, so the only remedy left is dropping cold
2347    /// entries.
2348    compact: bool,
2349}
2350
2351#[derive(Clone, Copy)]
2352struct GlyphInfo {
2353    u0: f32,
2354    v0: f32,
2355    u1: f32,
2356    v1: f32,
2357    w: f32,
2358    h: f32,
2359    /// `cache_touch_clock` at the last request, and the frame this glyph was
2360    /// last batched into. Entries in flight are never evicted: the instance
2361    /// buffer already holds their UVs, and a repack would move them.
2362    last_touch: u64,
2363    last_used_frame: u64,
2364}
2365
2366#[derive(Clone, Hash, PartialEq, Eq)]
2367struct TextShapeKey {
2368    text: Arc<str>,
2369    px_bits: u32,
2370    line_height_bits: u32,
2371    font_family: Option<&'static str>,
2372    font_weight: u16,
2373    font_style: u8,
2374    letter_spacing_bits: u32,
2375    variation: Option<Arc<str>>,
2376    font_generation: u64,
2377}
2378
2379struct TextShapeCacheEntry {
2380    glyphs: Arc<[repose_text::ShapedGlyph]>,
2381    last_used_frame: u64,
2382}
2383
2384/// Instance data for one shape. The brush enums share `bits` rather than
2385/// taking a varying each, and `grad_p0`/`grad_p1` merge into one `grad`
2386/// vector, to keep the fragment stage inside the varying budget that
2387/// `field_offset!` in `Pipelines::create` derives the attribute table from.
2388#[repr(C)]
2389#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2390struct RectInstance {
2391    xywh: [f32; 4],
2392    radii: [f32; 4],
2393    color0: [f32; 4],
2394    color1: [f32; 4],
2395    fwd_mat: [f32; 4],
2396    grad: [f32; 4],
2397    bits: u32,
2398    _pad: [f32; 3],
2399}
2400
2401#[repr(C)]
2402#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2403struct BorderInstance {
2404    xywh: [f32; 4],
2405    radii: [f32; 4],
2406    color0: [f32; 4],
2407    color1: [f32; 4],
2408    fwd_mat: [f32; 4],
2409    grad: [f32; 4],
2410    bits: u32,
2411    stroke: f32,
2412    _pad: [f32; 2],
2413}
2414
2415#[repr(C)]
2416#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2417struct EllipseInstance {
2418    xywh: [f32; 4],
2419    color0: [f32; 4],
2420    color1: [f32; 4],
2421    fwd_mat: [f32; 4],
2422    grad: [f32; 4],
2423    bits: u32,
2424    _pad: [f32; 1],
2425}
2426
2427#[repr(C)]
2428#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2429struct EllipseBorderInstance {
2430    xywh: [f32; 4],
2431    color0: [f32; 4],
2432    color1: [f32; 4],
2433    fwd_mat: [f32; 4],
2434    grad: [f32; 4],
2435    bits: u32,
2436    stroke: f32,
2437    pad: f32,
2438    _pad: f32,
2439}
2440
2441#[repr(C)]
2442#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2443struct ArcInstance {
2444    xywh: [f32; 4],
2445    color0: [f32; 4],
2446    color1: [f32; 4],
2447    fwd_mat: [f32; 4],
2448    grad: [f32; 4],
2449    bits: u32,
2450    start_angle: f32,
2451    sweep_angle: f32,
2452    stroke: f32,
2453    pad: f32,
2454    _pad: f32,
2455}
2456
2457#[repr(C)]
2458#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2459struct GlyphInstance {
2460    xywh: [f32; 4],
2461    uv: [f32; 4],
2462    color: [f32; 4],
2463    fwd_mat: [f32; 4],
2464}
2465
2466#[repr(C)]
2467#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2468struct BlurInstance {
2469    xywh: [f32; 4],
2470    uv: [f32; 4],
2471    color: [f32; 4],
2472    sigma_px: [f32; 2],
2473    fwd_mat: [f32; 4],
2474    axis: u32,
2475    edge_mode: u32,
2476    /// Corner radii, in source texels, shaping the blurred silhouette. All zero
2477    /// leaves it unshaped. Read only by the source-reading axis; the axis that
2478    /// samples the scratch must leave this zero.
2479    shape: [f32; 4],
2480}
2481
2482/// Projective layer-composite instance: the four layer-rect corners projected
2483/// to NDC (`c0..c3`, counter-clockwise from top-left) with their homogeneous
2484/// `w`, the layer-texture uv bounds, and a group alpha. Matches
2485/// `projective_layer.wgsl` (offsets: c0@0 c1@8 c2@16 c3@24 uv@32 w@48
2486/// alpha@64; stride 80).
2487#[repr(C)]
2488#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2489struct ProjectiveInstance {
2490    c0: [f32; 2],
2491    c1: [f32; 2],
2492    c2: [f32; 2],
2493    c3: [f32; 2],
2494    uv: [f32; 4],
2495    w: [f32; 4],
2496    alpha: f32,
2497    _pad: [f32; 3],
2498}
2499
2500/// CPU-computed Y′CbCr -> R′G′B′ transform uploaded as a uniform buffer.
2501/// Layout matches the WGSL `YuvTransform` struct (5 × vec4<f32>).
2502#[repr(C)]
2503#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2504struct YuvTransformRaw {
2505    row0: [f32; 4],
2506    row1: [f32; 4],
2507    row2: [f32; 4],
2508    b: [f32; 4],
2509    transfer: [f32; 4],
2510}
2511
2512fn make_yuv_transform_raw(color_info: ColorInfo, pixel_format: PixelFormat) -> YuvTransformRaw {
2513    let yuv = color_info.to_yuv_transform();
2514    let sample_scale = match pixel_format {
2515        PixelFormat::P010 => 65535.0 / (64.0 * 1020.0),
2516        _ => 1.0,
2517    };
2518    let transfer_mode = match color_info.transfer {
2519        repose_core::color::Transfer::Srgb => 0.0,
2520        repose_core::color::Transfer::Bt709 => 1.0,
2521        repose_core::color::Transfer::Linear => 2.0,
2522        repose_core::color::Transfer::Pq => 3.0,
2523        repose_core::color::Transfer::Hlg => 4.0,
2524    };
2525    YuvTransformRaw {
2526        row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2527        row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2528        row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2529        b: [yuv.b[0], yuv.b[1], yuv.b[2], sample_scale],
2530        transfer: [transfer_mode, 0.0, 0.0, 0.0],
2531    }
2532}
2533
2534#[repr(C)]
2535#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2536struct Nv12Instance {
2537    xywh: [f32; 4],
2538    uv: [f32; 4],
2539    color: [f32; 4],
2540    uv_x_offset: f32,
2541    uv_y_offset: f32,
2542    fwd_mat: [f32; 4],
2543}
2544
2545#[repr(C)]
2546#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2547struct ClipInstance {
2548    xywh: [f32; 4],
2549    radii: [f32; 4],
2550    fwd_mat: [f32; 4],
2551}
2552
2553/// Backdrop-blend composite instance: a `GlyphInstance`-shaped quad plus
2554/// the `BlendMode` discriminant consumed by `blend_layer.wgsl`.
2555#[repr(C)]
2556#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2557struct BlendInstance {
2558    xywh: [f32; 4],
2559    uv: [f32; 4],
2560    color: [f32; 4],
2561    fwd_mat: [f32; 4],
2562    mode: u32,
2563    _pad: [f32; 3],
2564}
2565
2566#[repr(C)]
2567#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2568struct MeshVertex {
2569    pos: [f32; 2],
2570    color: [f32; 4],
2571    uv: [f32; 2],
2572}
2573
2574#[derive(Clone)]
2575struct MeshGpu {
2576    vertices: wgpu::Buffer,
2577    indices: wgpu::Buffer,
2578    vertex_count: u32,
2579    index_count: u32,
2580    bytes: u64,
2581}
2582
2583#[derive(Clone, Copy, PartialEq, Eq, Hash)]
2584struct MeshCacheKey {
2585    source: usize,
2586    index: usize,
2587    kind: u8,
2588    vertex_len: usize,
2589    index_len: usize,
2590}
2591
2592#[derive(Clone)]
2593enum MeshOwner {
2594    Object(Arc<repose_core::VectorMeshData>),
2595    Overlay(Arc<[repose_core::VectorMeshData]>),
2596}
2597
2598struct MeshCacheEntry {
2599    owner: MeshOwner,
2600    gpu: MeshGpu,
2601    last_used_frame: u64,
2602    last_touch_frame: u64,
2603}
2604
2605#[derive(Clone, PartialEq, Eq, Hash)]
2606struct SlugDrawKey {
2607    cache_key: repose_text::CacheKey,
2608    fill: bool,
2609    /// The stroke parameters themselves, not a digest of them: a hash
2610    /// collision here would serve another stroke's tessellation.
2611    stroke: Option<slug::StrokeTessKey>,
2612    transform: [u32; 11],
2613    origin: [u32; 2],
2614    px_bits: u32,
2615    target: [u32; 2],
2616    color: [u32; 4],
2617}
2618
2619struct SlugDrawCacheEntry {
2620    vertices: Arc<[slug::TessVertex]>,
2621    bytes: u64,
2622    last_used_frame: u64,
2623    last_touch_frame: u64,
2624}
2625
2626/// Vector outline for one glyph, cached so repeated text at the same size does
2627/// not re-run outline extraction. `commands` is `None` for glyphs the extractor
2628/// rejects; that negative result is cached too, so a bad glyph is not retried.
2629struct GlyphOutlineEntry {
2630    commands: Option<Arc<[repose_text::Command]>>,
2631    last_touch: u64,
2632}
2633
2634/// Drop least-recently-used atlas entries until the live set fits `target_area`
2635/// pixels, skipping glyphs batched into the frame in flight. Never empties the
2636/// atlas, so a repack always has something left to pack around. Returns false
2637/// when nothing is evictable.
2638fn evict_atlas_lru(
2639    map: &mut HashMap<repose_text::CacheKey, GlyphInfo>,
2640    used_area: &mut u64,
2641    frame_index: u64,
2642    target_area: u64,
2643) -> bool {
2644    let mut victims: Vec<(u64, repose_text::CacheKey)> = map
2645        .iter()
2646        .filter(|(_, info)| info.last_used_frame != frame_index)
2647        .map(|(key, info)| (info.last_touch, key.clone()))
2648        .collect();
2649    if victims.is_empty() {
2650        return false;
2651    }
2652    // Ties are equally cold, so their relative order does not matter.
2653    victims.sort_unstable_by_key(|victim| victim.0);
2654    let mut evicted = false;
2655    for (_, key) in victims {
2656        if *used_area <= target_area || map.len() <= 1 {
2657            break;
2658        }
2659        if let Some(info) = map.remove(&key) {
2660            *used_area = used_area.saturating_sub(glyph_cell_area(&info));
2661            evicted = true;
2662        }
2663    }
2664    evicted
2665}
2666
2667/// Pixels a packed cell occupies. `w`/`h` are exact small integers by the time
2668/// they reach `GlyphInfo`, so the round trip through `f32` is lossless.
2669fn glyph_cell_area(info: &GlyphInfo) -> u64 {
2670    (info.w as u64).saturating_mul(info.h as u64)
2671}
2672
2673/// Remember that a glyph had no room, so it is skipped instead of re-rasterized
2674/// every frame until the next repack. Capped like the failure sets; dropping
2675/// the oldest entries only costs an earlier retry.
2676fn defer_atlas_glyph(deferred: &mut HashSet<repose_text::CacheKey>, key: repose_text::CacheKey) {
2677    if deferred.len() >= MAX_GLYPH_ATLAS_ENTRIES {
2678        deferred.clear();
2679    }
2680    deferred.insert(key);
2681}
2682
2683/// What one slug draw cache entry really costs: the vertex array plus the key,
2684/// the entry record and the map slot holding it. Charging only the array makes
2685/// the byte budget silently optimistic.
2686fn slug_draw_cache_entry_bytes(vertices: usize) -> u64 {
2687    let per_entry = (std::mem::size_of::<SlugDrawKey>()
2688        + std::mem::size_of::<SlugDrawCacheEntry>()
2689        + std::mem::size_of::<(SlugDrawKey, SlugDrawCacheEntry)>()) as u64;
2690    u64::try_from(vertices)
2691        .ok()
2692        .and_then(|count| count.checked_mul(std::mem::size_of::<slug::TessVertex>() as u64))
2693        .and_then(|payload| payload.checked_add(per_entry))
2694        .unwrap_or(u64::MAX)
2695}
2696
2697/// Per-draw vector mesh uniform. Field order and every member width are
2698/// load-bearing: the WGSL `MeshUniform` in `shaders/mesh.wgsl` must match it
2699/// member for member, and uniform address space forces 16-byte alignment, so
2700/// the two-point gradient endpoints are padded out to `vec4`s and the stop
2701/// table is one `vec4` per colour plus four offsets per `vec4` (never a bare
2702/// `array<f32>`). `paint.z` carries the live stop count (0..=8).
2703#[repr(C)]
2704#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2705struct MeshUniform {
2706    paint: [u32; 4],
2707    color0: [f32; 4],
2708    color1: [f32; 4],
2709    grad_start: [f32; 2],
2710    _p3: [f32; 2],
2711    grad_end: [f32; 2],
2712    _p4: [f32; 2],
2713    stop_colors: [[f32; 4]; MAX_GRADIENT_STOPS],
2714    stop_offsets: [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2715}
2716
2717/// Bytes `MeshUniform` occupies. `tests::mesh_uniform_matches_the_wgsl_member_layout`
2718/// parses `shaders/mesh.wgsl` and checks every member offset and the total
2719/// size against the struct, so the two halves cannot drift apart.
2720const MESH_UNIFORM_BYTES: usize = 240;
2721const _: () = assert!(std::mem::size_of::<MeshUniform>() == MESH_UNIFORM_BYTES);
2722
2723const MESH_UNIFORM_CAP: u64 = 4 * 1024 * 1024;
2724
2725/// One placement of a mesh, as a per-instance vertex record. Slot order is the
2726/// canonical `[A, B, tx, C, D, ty]` consumed by `vs_main`: rows land in
2727/// `xf0`/`xf1`, matching `combine_mesh_affine` and `mesh_aabb`.
2728#[repr(C)]
2729#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
2730struct MeshInstance {
2731    xf0: [f32; 4],
2732    xf1: [f32; 4],
2733}
2734
2735impl MeshInstance {
2736    fn new(affine: [f32; 6]) -> Self {
2737        Self {
2738            xf0: [affine[0], affine[1], affine[2], 0.0],
2739            xf1: [affine[3], affine[4], affine[5], 0.0],
2740        }
2741    }
2742}
2743
2744/// Placement records per frame. 32 B each, so this holds 262144 placements -
2745/// far past a dense 2D frame, and past the point where the CPU-side walk is
2746/// the bottleneck rather than the draw count.
2747const MESH_INSTANCE_CAP: u64 = 8 * 1024 * 1024;
2748
2749/// Canonical identity placement, for meshes already baked in world space
2750/// (vector clips and screen-space overlays).
2751const IDENTITY_AFFINE: [f32; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
2752const MAX_RETAINED_IMAGE_BYTES: u64 = 256 * 1024 * 1024;
2753const MAX_RETAINED_IMAGES: usize = 512;
2754const MAX_BLEND_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
2755const MAX_BLEND_SNAPSHOT_POOL_ENTRIES: usize = 64;
2756const MAX_GLYPH_ATLAS_ENTRIES: usize = 32768;
2757/// Hard ceiling on atlas edge length. Past this the atlas is compacted instead
2758/// of grown, dropping least-recently-used glyphs.
2759const MAX_GLYPH_ATLAS_SIZE: u32 = 4096;
2760/// Fraction of the live set a compaction keeps. Trimming to a fraction of the
2761/// current area (rather than an absolute budget) guarantees a repack always
2762/// reclaims shelf space, even when the atlas is only a few large glyphs deep.
2763const ATLAS_COMPACT_KEEP_NUM: u64 = 3;
2764const ATLAS_COMPACT_KEEP_DEN: u64 = 4;
2765/// Minimum frames between compactions. A working set that genuinely exceeds
2766/// atlas capacity then costs a few missing glyphs per interval instead of
2767/// re-uploading the whole atlas every frame.
2768const ATLAS_COMPACT_MIN_FRAME_INTERVAL: u64 = 30;
2769const MAX_GPU_RESOURCE_BYTES: u64 = 1024 * 1024 * 1024;
2770const MAX_UPLOAD_RING_BYTES: u64 = 64 * 1024 * 1024;
2771const MAX_MESH_CACHE_BYTES: u64 = 256 * 1024 * 1024;
2772const MAX_MESH_CACHE_ENTRIES: usize = 512;
2773const MAX_SLUG_DRAW_CACHE_ENTRIES: usize = 2048;
2774const MAX_SLUG_DRAW_CACHE_BYTES: u64 = 32 * 1024 * 1024;
2775const MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES: usize = 256;
2776const MAX_GLYPH_OUTLINE_CACHE_ENTRIES: usize = 4096;
2777const MAX_IMAGE_DECODE_BYTES: u64 = 64 * 1024 * 1024;
2778const MAX_CALLBACK_SCOPES: usize = 256;
2779const NV12_FOURCC: u32 = 0x3231_564e;
2780const P010_FOURCC: u32 = 0x3031_3050;
2781
2782fn canonical_fourcc(format: PixelFormat) -> u32 {
2783    match format {
2784        PixelFormat::P010 => P010_FOURCC,
2785        _ => NV12_FOURCC,
2786    }
2787}
2788
2789impl MeshUniform {
2790    fn identity() -> Self {
2791        Self {
2792            paint: [0; 4],
2793            color0: [0.0; 4],
2794            color1: [0.0; 4],
2795            grad_start: [0.0; 2],
2796            _p3: [0.0; 2],
2797            grad_end: [0.0; 2],
2798            _p4: [0.0; 2],
2799            stop_colors: [[0.0; 4]; MAX_GRADIENT_STOPS],
2800            stop_offsets: [[0.0; 4]; MAX_GRADIENT_STOPS / 4],
2801        }
2802    }
2803}
2804
2805/// Colours, packed offsets and the live count, ready for `MeshUniform`.
2806type PackedStops = (
2807    [[f32; 4]; MAX_GRADIENT_STOPS],
2808    [[f32; 4]; MAX_GRADIENT_STOPS / 4],
2809    u32,
2810);
2811
2812/// Packs a stop table for the uniform: one `vec4` per colour, four offsets per
2813/// `vec4`, plus the live count (0..=8). Offsets are clamped to `0..=1` and
2814/// forced non-decreasing so the shader can scan them in order without sorting;
2815/// a NaN offset folds to the previous stop's (`f32::max` drops NaN). Order is
2816/// otherwise preserved, so repeated offsets stay a hard stop at that position.
2817fn pack_gradient_stops(stops: &GradientStops) -> PackedStops {
2818    let mut stop_colors = [[0.0f32; 4]; MAX_GRADIENT_STOPS];
2819    let mut flat = [0.0f32; MAX_GRADIENT_STOPS];
2820    let mut count = 0u32;
2821    let mut prev = 0.0f32;
2822    for ((color, offset), stop) in stop_colors
2823        .iter_mut()
2824        .zip(flat.iter_mut())
2825        .zip(stops.as_slice())
2826    {
2827        let clamped = stop.offset.clamp(0.0, 1.0).max(prev);
2828        prev = clamped;
2829        *color = stop.color.to_linear();
2830        *offset = clamped;
2831        count += 1;
2832    }
2833    let mut stop_offsets = [[0.0f32; 4]; MAX_GRADIENT_STOPS / 4];
2834    for (dst, src) in stop_offsets.iter_mut().zip(flat.as_chunks::<4>().0) {
2835        *dst = *src;
2836    }
2837    (stop_colors, stop_offsets, count)
2838}
2839
2840fn mesh_uniform_from_paint(paint: &repose_core::PaintDesc) -> MeshUniform {
2841    let no_stops = GradientStops::default();
2842    let (paint_type, paint_kind, stops, color0, color1, grad_start, grad_end) = match paint {
2843        repose_core::PaintDesc::Solid => {
2844            (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2])
2845        }
2846        repose_core::PaintDesc::Linear {
2847            start,
2848            end,
2849            start_color,
2850            end_color,
2851        } => (
2852            1u32,
2853            0u32,
2854            no_stops,
2855            start_color.to_linear(),
2856            end_color.to_linear(),
2857            [start.x, start.y],
2858            [end.x, end.y],
2859        ),
2860        repose_core::PaintDesc::Radial {
2861            center,
2862            radius,
2863            start_color,
2864            end_color,
2865        } => (
2866            1u32,
2867            1u32,
2868            no_stops,
2869            start_color.to_linear(),
2870            end_color.to_linear(),
2871            [center.x, center.y],
2872            [radius.max(0.0), 0.0],
2873        ),
2874        repose_core::PaintDesc::Sweep {
2875            center,
2876            start_color,
2877            end_color,
2878        } => (
2879            1u32,
2880            2u32,
2881            no_stops,
2882            start_color.to_linear(),
2883            end_color.to_linear(),
2884            [center.x, center.y],
2885            [0.0, 0.0],
2886        ),
2887        repose_core::PaintDesc::LinearStops { start, end, stops } => (
2888            1u32,
2889            3u32,
2890            *stops,
2891            [0.0; 4],
2892            [0.0; 4],
2893            [start.x, start.y],
2894            [end.x, end.y],
2895        ),
2896        repose_core::PaintDesc::RadialStops {
2897            center,
2898            radius,
2899            stops,
2900        } => (
2901            1u32,
2902            4u32,
2903            *stops,
2904            [0.0; 4],
2905            [0.0; 4],
2906            [center.x, center.y],
2907            [radius.max(0.0), 0.0],
2908        ),
2909        _ => (0u32, 0u32, no_stops, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
2910    };
2911    let (stop_colors, stop_offsets, count) = pack_gradient_stops(&stops);
2912    MeshUniform {
2913        paint: [paint_type, paint_kind, count, 0],
2914        color0,
2915        color1,
2916        grad_start,
2917        _p3: [0.0; 2],
2918        grad_end,
2919        _p4: [0.0; 2],
2920        stop_colors,
2921        stop_offsets,
2922    }
2923}
2924
2925fn combine_mesh_affine(current: &Transform, mesh: [f32; 6]) -> [f32; 6] {
2926    let cm = current.linear();
2927    let (cm00, cm01, cm10, cm11) = (cm[0], cm[1], cm[2], cm[3]);
2928    let mm00 = mesh[0];
2929    let mm01 = mesh[1];
2930    let mm10 = mesh[2];
2931    let mm11 = mesh[3];
2932    let mtx = mesh[4];
2933    let mty = mesh[5];
2934    let r00 = cm00 * mm00 + cm01 * mm10;
2935    let r01 = cm00 * mm01 + cm01 * mm11;
2936    let r10 = cm10 * mm00 + cm11 * mm10;
2937    let r11 = cm10 * mm01 + cm11 * mm11;
2938    let tx = cm00 * mtx + cm01 * mty + current.translate_x;
2939    let ty = cm10 * mtx + cm11 * mty + current.translate_y;
2940    // Canonical slot order consumed by `MeshUniform`/shader and `mesh_aabb`:
2941    // [A, B, tx, C, D, ty] where world = [[A,B],[C,D]] * local + (tx, ty).
2942    [r00, r01, tx, r10, r11, ty]
2943}
2944
2945/// True when this mesh+paint+blend combination cannot be drawn straight into
2946/// the current pass: a backdrop-dependent mode always needs its own isolated
2947/// source layer, and a fixed-function blend needs one too when the geometry or
2948/// paint is translucent (the pipeline would otherwise blend against itself).
2949/// Such draws cannot share an instance run, so they take the per-placement
2950/// path.
2951fn mesh_blend_needs_isolation(
2952    blend: repose_core::BlendMode,
2953    mesh: &repose_core::VectorMeshData,
2954    paint: &repose_core::PaintDesc,
2955) -> bool {
2956    if blend.needs_isolation() {
2957        return true;
2958    }
2959    let paint_alpha = match paint {
2960        repose_core::PaintDesc::Linear {
2961            start_color,
2962            end_color,
2963            ..
2964        }
2965        | repose_core::PaintDesc::Radial {
2966            start_color,
2967            end_color,
2968            ..
2969        }
2970        | repose_core::PaintDesc::Sweep {
2971            start_color,
2972            end_color,
2973            ..
2974        } => Some(start_color.3.min(end_color.3) as f32 / 255.0),
2975        repose_core::PaintDesc::LinearStops { stops, .. }
2976        | repose_core::PaintDesc::RadialStops { stops, .. } => Some(stops.min_alpha()),
2977        _ => None,
2978    };
2979    matches!(
2980        blend,
2981        repose_core::BlendMode::Add
2982            | repose_core::BlendMode::Multiply
2983            | repose_core::BlendMode::Screen
2984            | repose_core::BlendMode::Darken
2985            | repose_core::BlendMode::Lighten
2986    ) && (mesh.vertices.iter().any(|vertex| vertex.color[3] < 0.9999)
2987        || paint_alpha.is_some_and(|alpha| alpha < 0.9999))
2988}
2989
2990fn mesh_aabb(mesh: &repose_core::VectorMeshData, affine: [f32; 6]) -> repose_core::Rect {
2991    let mut min_x = f32::INFINITY;
2992    let mut min_y = f32::INFINITY;
2993    let mut max_x = f32::NEG_INFINITY;
2994    let mut max_y = f32::NEG_INFINITY;
2995    for v in mesh.vertices.iter() {
2996        let x = affine[0] * v.pos[0] + affine[1] * v.pos[1] + affine[2];
2997        let y = affine[3] * v.pos[0] + affine[4] * v.pos[1] + affine[5];
2998        min_x = min_x.min(x);
2999        min_y = min_y.min(y);
3000        max_x = max_x.max(x);
3001        max_y = max_y.max(y);
3002    }
3003    let w = (max_x - min_x).max(0.0);
3004    let h = (max_y - min_y).max(0.0);
3005    if !min_x.is_finite() || !min_y.is_finite() {
3006        return repose_core::Rect {
3007            x: 0.0,
3008            y: 0.0,
3009            w: 0.0,
3010            h: 0.0,
3011        };
3012    }
3013    repose_core::Rect {
3014        x: min_x,
3015        y: min_y,
3016        w,
3017        h,
3018    }
3019}
3020
3021fn quantize_glyph_px(px: f32) -> f32 {
3022    if !px.is_finite() || px <= 0.0 {
3023        return 1.0;
3024    }
3025    ((px * 4.0).round() / 4.0).max(0.25)
3026}
3027
3028fn swash_to_a8_coverage(content: repose_text::SwashContent, data: &[u8]) -> Option<Vec<u8>> {
3029    match content {
3030        repose_text::SwashContent::Mask => Some(data.to_vec()),
3031        repose_text::SwashContent::SubpixelMask => {
3032            let mut out = Vec::with_capacity(data.len() / 4);
3033            for px in data.as_chunks::<4>().0 {
3034                let r = px[0];
3035                let g = px[1];
3036                let b = px[2];
3037                out.push(r.max(g).max(b));
3038            }
3039            Some(out)
3040        }
3041        repose_text::SwashContent::Color => None,
3042    }
3043}
3044
3045impl WgpuSceneRenderer {
3046    /// Construct a renderer for `output_format` with `msaa_samples` samples.
3047    ///
3048    /// Validates what is checkable without an adapter: `wgpu` only accepts
3049    /// sample counts of 1, 2, 4, 8 or 16, and an unrenderable or
3050    /// unblendable output format fails later as a validation error rather
3051    /// than here. Prefer
3052    /// [`try_from_device_with_adapter`](Self::try_from_device_with_adapter)
3053    /// whenever an adapter is available - it also checks the format's
3054    /// render-attachment, blend and multisample-resolve support and picks a
3055    /// supported sample count.
3056    pub fn from_device(
3057        device: wgpu::Device,
3058        queue: wgpu::Queue,
3059        output_format: wgpu::TextureFormat,
3060        msaa_samples: u32,
3061    ) -> anyhow::Result<Self> {
3062        validate_sample_count("msaa_samples", msaa_samples)?;
3063        Self::from_device_with_working_space_msaa(
3064            device,
3065            queue,
3066            output_format,
3067            msaa_samples,
3068            msaa_samples,
3069        )
3070    }
3071
3072    /// Validated constructor with an adapter: rejects an output format that
3073    /// cannot back the UI pipelines and clamps `requested_msaa` to the
3074    /// largest supported count at or below it.
3075    pub fn try_from_device_with_adapter(
3076        adapter: &wgpu::Adapter,
3077        device: wgpu::Device,
3078        queue: wgpu::Queue,
3079        output_format: wgpu::TextureFormat,
3080        requested_msaa: u32,
3081    ) -> anyhow::Result<Self> {
3082        let msaa = pick_surface_msaa(adapter, output_format, requested_msaa);
3083        validate_render_target_format(adapter, output_format, msaa)?;
3084        let working_space_msaa =
3085            pick_surface_msaa_for_mode(adapter, wgpu::TextureFormat::Rgba16Float, msaa, true);
3086        let mut renderer = Self::from_device_with_working_space_msaa(
3087            device,
3088            queue,
3089            output_format,
3090            msaa,
3091            working_space_msaa,
3092        )?;
3093        // Only the adapter knows the device's downlevel capabilities, so
3094        // publish them for callbacks to read via
3095        // `resources.get::<wgpu::DownlevelCapabilities>()`.
3096        renderer
3097            .callback_resources
3098            .insert(adapter.get_downlevel_capabilities());
3099        Ok(renderer)
3100    }
3101
3102    /// Point the renderer at a surface whose texture format differs from
3103    /// [`Self::output_format`], as a canvas configured with `viewFormats` does.
3104    /// Call this before the first frame.
3105    pub fn with_surface_format(
3106        mut self,
3107        surface_format: wgpu::TextureFormat,
3108    ) -> anyhow::Result<Self> {
3109        if surface_format != self.output_format
3110            && surface_format.add_srgb_suffix() != self.output_format
3111        {
3112            anyhow::bail!(
3113                "surface format {surface_format:?} cannot be reinterpreted as {:?}",
3114                self.output_format
3115            );
3116        }
3117        if self.surface_format != surface_format {
3118            self.surface_format = surface_format;
3119            self.recreate_msaa_and_depth_stencil();
3120        }
3121        Ok(self)
3122    }
3123
3124    pub fn from_device_with_working_space_msaa(
3125        device: wgpu::Device,
3126        queue: wgpu::Queue,
3127        output_format: wgpu::TextureFormat,
3128        msaa_samples: u32,
3129        working_space_msaa_samples: u32,
3130    ) -> anyhow::Result<Self> {
3131        validate_sample_count("msaa_samples", msaa_samples)?;
3132        validate_sample_count("working_space_msaa_samples", working_space_msaa_samples)?;
3133        let msaa_samples = msaa_samples.max(1);
3134        let working_space_msaa_samples = working_space_msaa_samples.max(1);
3135        let mesh_uniform_size = std::mem::size_of::<MeshUniform>() as u64;
3136        let base_mesh_alignment =
3137            u64::from(device.limits().min_uniform_buffer_offset_alignment).max(4);
3138        let mesh_uniform_alignment = mesh_uniform_stride(mesh_uniform_size, base_mesh_alignment);
3139        let mesh_instance_cap = MESH_INSTANCE_CAP
3140            .min(device.limits().max_buffer_size)
3141            .max(4);
3142        let mesh_uniform_cap = align_up(
3143            MESH_UNIFORM_CAP
3144                .min(device.limits().max_buffer_size)
3145                .max(mesh_uniform_alignment),
3146            base_mesh_alignment,
3147        )
3148        .unwrap_or(mesh_uniform_alignment);
3149        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3150            label: Some("globals layout"),
3151            entries: &[wgpu::BindGroupLayoutEntry {
3152                binding: 0,
3153                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3154                ty: wgpu::BindingType::Buffer {
3155                    ty: wgpu::BufferBindingType::Uniform,
3156                    has_dynamic_offset: false,
3157                    min_binding_size: None,
3158                },
3159                count: None,
3160            }],
3161        });
3162
3163        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
3164            label: Some("globals buf"),
3165            size: std::mem::size_of::<Globals>() as u64,
3166            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3167            mapped_at_creation: false,
3168        });
3169
3170        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3171            label: Some("globals bind"),
3172            layout: &globals_layout,
3173            entries: &[wgpu::BindGroupEntry {
3174                binding: 0,
3175                resource: globals_buf.as_entire_binding(),
3176            }],
3177        });
3178        let globals_staging_cap =
3179            (64 * std::mem::size_of::<Globals>() as u64).max(std::mem::size_of::<Globals>() as u64);
3180        let globals_staging = device.create_buffer(&wgpu::BufferDescriptor {
3181            label: Some("globals staging"),
3182            size: globals_staging_cap,
3183            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
3184            mapped_at_creation: false,
3185        });
3186
3187        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
3188
3189        let stencil_for_content = wgpu::DepthStencilState {
3190            format: ds_format,
3191            depth_write_enabled: Some(false),
3192            depth_compare: Some(wgpu::CompareFunction::Always),
3193            stencil: wgpu::StencilState {
3194                front: wgpu::StencilFaceState {
3195                    // Equal (not LessEqual): inverse (`Difference`) vector
3196                    // masks work by keeping the depth while incrementing the
3197                    // masked pixels, so content must test exact equality.
3198                    // Outcomes match LessEqual everywhere except pre-existing
3199                    // stencil leaks, which now fail visibly instead of
3200                    // drawing through (unbalanced clips already warn).
3201                    compare: wgpu::CompareFunction::Equal,
3202                    fail_op: wgpu::StencilOperation::Keep,
3203                    depth_fail_op: wgpu::StencilOperation::Keep,
3204                    pass_op: wgpu::StencilOperation::Keep,
3205                },
3206                back: wgpu::StencilFaceState {
3207                    compare: wgpu::CompareFunction::Equal,
3208                    fail_op: wgpu::StencilOperation::Keep,
3209                    depth_fail_op: wgpu::StencilOperation::Keep,
3210                    pass_op: wgpu::StencilOperation::Keep,
3211                },
3212                read_mask: 0xFF,
3213                write_mask: 0x00,
3214            },
3215            bias: wgpu::DepthBiasState::default(),
3216        };
3217
3218        let stencil_for_clip_inc = wgpu::DepthStencilState {
3219            format: ds_format,
3220            depth_write_enabled: Some(false),
3221            depth_compare: Some(wgpu::CompareFunction::Always),
3222            stencil: wgpu::StencilState {
3223                front: wgpu::StencilFaceState {
3224                    compare: wgpu::CompareFunction::Always,
3225                    fail_op: wgpu::StencilOperation::Keep,
3226                    depth_fail_op: wgpu::StencilOperation::Keep,
3227                    pass_op: wgpu::StencilOperation::IncrementClamp,
3228                },
3229                back: wgpu::StencilFaceState {
3230                    compare: wgpu::CompareFunction::Always,
3231                    fail_op: wgpu::StencilOperation::Keep,
3232                    depth_fail_op: wgpu::StencilOperation::Keep,
3233                    pass_op: wgpu::StencilOperation::IncrementClamp,
3234                },
3235                read_mask: 0xFF,
3236                write_mask: 0xFF,
3237            },
3238            bias: wgpu::DepthBiasState::default(),
3239        };
3240
3241        let stencil_for_clip_dec = wgpu::DepthStencilState {
3242            format: ds_format,
3243            depth_write_enabled: Some(false),
3244            depth_compare: Some(wgpu::CompareFunction::Always),
3245            stencil: wgpu::StencilState {
3246                front: wgpu::StencilFaceState {
3247                    compare: wgpu::CompareFunction::Always,
3248                    fail_op: wgpu::StencilOperation::Keep,
3249                    depth_fail_op: wgpu::StencilOperation::Keep,
3250                    pass_op: wgpu::StencilOperation::DecrementClamp,
3251                },
3252                back: wgpu::StencilFaceState {
3253                    compare: wgpu::CompareFunction::Always,
3254                    fail_op: wgpu::StencilOperation::Keep,
3255                    depth_fail_op: wgpu::StencilOperation::Keep,
3256                    pass_op: wgpu::StencilOperation::DecrementClamp,
3257                },
3258                read_mask: 0xFF,
3259                write_mask: 0xFF,
3260            },
3261            bias: wgpu::DepthBiasState::default(),
3262        };
3263
3264        let _multisample_state = wgpu::MultisampleState {
3265            count: msaa_samples,
3266            mask: !0,
3267            alpha_to_coverage_enabled: false,
3268        };
3269
3270        // PIPELINES
3271
3272        // Single shared sampler for images/text
3273        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3274            label: Some("image/text sampler"),
3275            address_mode_u: wgpu::AddressMode::ClampToEdge,
3276            address_mode_v: wgpu::AddressMode::ClampToEdge,
3277            mag_filter: wgpu::FilterMode::Linear,
3278            min_filter: wgpu::FilterMode::Linear,
3279            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3280            ..Default::default()
3281        });
3282        let image_sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3283            label: Some("image nearest sampler"),
3284            address_mode_u: wgpu::AddressMode::ClampToEdge,
3285            address_mode_v: wgpu::AddressMode::ClampToEdge,
3286            mag_filter: wgpu::FilterMode::Nearest,
3287            min_filter: wgpu::FilterMode::Nearest,
3288            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3289            ..Default::default()
3290        });
3291
3292        // `ImageFit::Tile` emits UVs past 1.0 and relies on wrapping, so it
3293        // needs its own samplers rather than the clamped pair above.
3294        let image_sampler_repeat = device.create_sampler(&wgpu::SamplerDescriptor {
3295            label: Some("image repeat sampler"),
3296            address_mode_u: wgpu::AddressMode::Repeat,
3297            address_mode_v: wgpu::AddressMode::Repeat,
3298            mag_filter: wgpu::FilterMode::Linear,
3299            min_filter: wgpu::FilterMode::Linear,
3300            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3301            ..Default::default()
3302        });
3303        let image_sampler_repeat_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3304            label: Some("image repeat nearest sampler"),
3305            address_mode_u: wgpu::AddressMode::Repeat,
3306            address_mode_v: wgpu::AddressMode::Repeat,
3307            mag_filter: wgpu::FilterMode::Nearest,
3308            min_filter: wgpu::FilterMode::Nearest,
3309            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3310            ..Default::default()
3311        });
3312
3313        // linear filtering only blurs them; nearest keeps the blit crisp.
3314        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3315            label: Some("layer nearest sampler"),
3316            address_mode_u: wgpu::AddressMode::ClampToEdge,
3317            address_mode_v: wgpu::AddressMode::ClampToEdge,
3318            mag_filter: wgpu::FilterMode::Nearest,
3319            min_filter: wgpu::FilterMode::Nearest,
3320            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3321            ..Default::default()
3322        });
3323
3324        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
3325        // the sharp 1:1 layer composite.
3326        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
3327            label: Some("layer linear sampler"),
3328            address_mode_u: wgpu::AddressMode::ClampToEdge,
3329            address_mode_v: wgpu::AddressMode::ClampToEdge,
3330            mag_filter: wgpu::FilterMode::Linear,
3331            min_filter: wgpu::FilterMode::Linear,
3332            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3333            ..Default::default()
3334        });
3335
3336        // Layout for Text / RGBA Images (Texture + Sampler)
3337        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3338            label: Some("text/rgba bind layout"),
3339            entries: &[
3340                wgpu::BindGroupLayoutEntry {
3341                    binding: 0,
3342                    visibility: wgpu::ShaderStages::FRAGMENT,
3343                    ty: wgpu::BindingType::Texture {
3344                        multisampled: false,
3345                        view_dimension: wgpu::TextureViewDimension::D2,
3346                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3347                    },
3348                    count: None,
3349                },
3350                wgpu::BindGroupLayoutEntry {
3351                    binding: 1,
3352                    visibility: wgpu::ShaderStages::FRAGMENT,
3353                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3354                    count: None,
3355                },
3356            ],
3357        });
3358        // We reuse this for RGBA images for simplicity, or create a distinct one
3359        let image_bind_layout_rgba = text_bind_layout.clone();
3360
3361        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
3362        let image_bind_layout_nv12 =
3363            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3364                label: Some("image bind layout nv12"),
3365                entries: &[
3366                    // Y plane
3367                    wgpu::BindGroupLayoutEntry {
3368                        binding: 0,
3369                        visibility: wgpu::ShaderStages::FRAGMENT,
3370                        ty: wgpu::BindingType::Texture {
3371                            multisampled: false,
3372                            view_dimension: wgpu::TextureViewDimension::D2,
3373                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3374                        },
3375                        count: None,
3376                    },
3377                    // UV plane
3378                    wgpu::BindGroupLayoutEntry {
3379                        binding: 1,
3380                        visibility: wgpu::ShaderStages::FRAGMENT,
3381                        ty: wgpu::BindingType::Texture {
3382                            multisampled: false,
3383                            view_dimension: wgpu::TextureViewDimension::D2,
3384                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3385                        },
3386                        count: None,
3387                    },
3388                    // Sampler
3389                    wgpu::BindGroupLayoutEntry {
3390                        binding: 2,
3391                        visibility: wgpu::ShaderStages::FRAGMENT,
3392                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3393                        count: None,
3394                    },
3395                    // YUV transform uniform buffer
3396                    wgpu::BindGroupLayoutEntry {
3397                        binding: 3,
3398                        visibility: wgpu::ShaderStages::FRAGMENT,
3399                        ty: wgpu::BindingType::Buffer {
3400                            ty: wgpu::BufferBindingType::Uniform,
3401                            has_dynamic_offset: false,
3402                            min_binding_size: None,
3403                        },
3404                        count: None,
3405                    },
3406                ],
3407            });
3408
3409        // Clipping layout
3410        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3411            label: Some("clip pipeline layout"),
3412            bind_group_layouts: &[Some(&globals_layout)],
3413            immediate_size: 0,
3414        });
3415        let clip_vertex_layout = wgpu::VertexBufferLayout {
3416            array_stride: std::mem::size_of::<ClipInstance>() as u64,
3417            step_mode: wgpu::VertexStepMode::Instance,
3418            attributes: &[
3419                wgpu::VertexAttribute {
3420                    shader_location: 0,
3421                    offset: 0,
3422                    format: wgpu::VertexFormat::Float32x4,
3423                },
3424                wgpu::VertexAttribute {
3425                    shader_location: 1,
3426                    offset: 16,
3427                    format: wgpu::VertexFormat::Float32x4,
3428                },
3429                wgpu::VertexAttribute {
3430                    shader_location: 2,
3431                    offset: 32,
3432                    format: wgpu::VertexFormat::Float32x4,
3433                },
3434            ],
3435        };
3436        // Bind layout for per-draw vector mesh uniforms (dynamic offset).
3437        let mesh_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3438            label: Some("mesh uniform layout"),
3439            entries: &[wgpu::BindGroupLayoutEntry {
3440                binding: 0,
3441                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3442                ty: wgpu::BindingType::Buffer {
3443                    ty: wgpu::BufferBindingType::Uniform,
3444                    has_dynamic_offset: true,
3445                    min_binding_size: NonZeroU64::new(mesh_uniform_size),
3446                },
3447                count: None,
3448            }],
3449        });
3450        let mesh_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3451            label: Some("mesh uniform buffer"),
3452            size: mesh_uniform_cap,
3453            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3454            mapped_at_creation: false,
3455        });
3456        let mesh_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
3457            label: Some("mesh instance buffer"),
3458            size: mesh_instance_cap,
3459            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3460            mapped_at_creation: false,
3461        });
3462        let mesh_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3463            label: Some("mesh uniform bind"),
3464            layout: &mesh_bind_layout,
3465            entries: &[wgpu::BindGroupEntry {
3466                binding: 0,
3467                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3468                    buffer: &mesh_uniform_buf,
3469                    offset: 0,
3470                    size: NonZeroU64::new(mesh_uniform_size),
3471                }),
3472            }],
3473        });
3474
3475        // Two sets of pipelines: one for the MSAA surface pass, one for layer
3476        // render-to-texture passes (sample_count = 1).
3477        let surface_pipes = Pipelines::create(
3478            &device,
3479            output_format,
3480            msaa_samples,
3481            &globals_layout,
3482            &text_bind_layout,
3483            &image_bind_layout_nv12,
3484            &clip_pipeline_layout,
3485            &stencil_for_content,
3486            &stencil_for_clip_inc,
3487            &stencil_for_clip_dec,
3488            &clip_vertex_layout,
3489            &mesh_bind_layout,
3490        );
3491        let working_space_pipes = Pipelines::create(
3492            &device,
3493            wgpu::TextureFormat::Rgba16Float,
3494            working_space_msaa_samples,
3495            &globals_layout,
3496            &text_bind_layout,
3497            &image_bind_layout_nv12,
3498            &clip_pipeline_layout,
3499            &stencil_for_content,
3500            &stencil_for_clip_inc,
3501            &stencil_for_clip_dec,
3502            &clip_vertex_layout,
3503            &mesh_bind_layout,
3504        );
3505        let layer_pipes = if msaa_samples == 1 {
3506            surface_pipes.clone()
3507        } else {
3508            Pipelines::create(
3509                &device,
3510                output_format,
3511                1,
3512                &globals_layout,
3513                &text_bind_layout,
3514                &image_bind_layout_nv12,
3515                &clip_pipeline_layout,
3516                &stencil_for_content,
3517                &stencil_for_clip_inc,
3518                &stencil_for_clip_dec,
3519                &clip_vertex_layout,
3520                &mesh_bind_layout,
3521            )
3522        };
3523        let working_space_layer_pipes = if working_space_msaa_samples == 1 {
3524            working_space_pipes.clone()
3525        } else {
3526            Pipelines::create(
3527                &device,
3528                wgpu::TextureFormat::Rgba16Float,
3529                1,
3530                &globals_layout,
3531                &text_bind_layout,
3532                &image_bind_layout_nv12,
3533                &clip_pipeline_layout,
3534                &stencil_for_content,
3535                &stencil_for_clip_inc,
3536                &stencil_for_clip_dec,
3537                &clip_vertex_layout,
3538                &mesh_bind_layout,
3539            )
3540        };
3541
3542        // Vector glyph rendering always available with tessellation+MSAA approach.
3543        let slug_enabled = true;
3544
3545        // Blur composite ring (for graphics-layer drop shadows)
3546        let blur_ring = UploadRing::new(
3547            &device,
3548            "blur ring",
3549            1024 * 1024,
3550            wgpu::BufferUsages::VERTEX
3551                | wgpu::BufferUsages::COPY_DST
3552                | wgpu::BufferUsages::COPY_SRC,
3553        );
3554
3555        // Atlases
3556        let atlas_mask = init_atlas_mask(&device);
3557        let atlas_color = init_atlas_color(&device);
3558        let atlas_mask_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3559            label: Some("atlas mask bind"),
3560            layout: &text_bind_layout,
3561            entries: &[
3562                wgpu::BindGroupEntry {
3563                    binding: 0,
3564                    resource: wgpu::BindingResource::TextureView(&atlas_mask.view),
3565                },
3566                wgpu::BindGroupEntry {
3567                    binding: 1,
3568                    resource: wgpu::BindingResource::Sampler(&atlas_mask.sampler),
3569                },
3570            ],
3571        });
3572        let atlas_color_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3573            label: Some("atlas color bind"),
3574            layout: &text_bind_layout,
3575            entries: &[
3576                wgpu::BindGroupEntry {
3577                    binding: 0,
3578                    resource: wgpu::BindingResource::TextureView(&atlas_color.view),
3579                },
3580                wgpu::BindGroupEntry {
3581                    binding: 1,
3582                    resource: wgpu::BindingResource::Sampler(&atlas_color.sampler),
3583                },
3584            ],
3585        });
3586
3587        // Upload rings
3588        let ring_rect = UploadRing::new(
3589            &device,
3590            "ring rect",
3591            1 << 20,
3592            wgpu::BufferUsages::VERTEX
3593                | wgpu::BufferUsages::COPY_DST
3594                | wgpu::BufferUsages::COPY_SRC,
3595        );
3596        let ring_border = UploadRing::new(
3597            &device,
3598            "ring border",
3599            1 << 20,
3600            wgpu::BufferUsages::VERTEX
3601                | wgpu::BufferUsages::COPY_DST
3602                | wgpu::BufferUsages::COPY_SRC,
3603        );
3604        let ring_ellipse = UploadRing::new(
3605            &device,
3606            "ring ellipse",
3607            1 << 20,
3608            wgpu::BufferUsages::VERTEX
3609                | wgpu::BufferUsages::COPY_DST
3610                | wgpu::BufferUsages::COPY_SRC,
3611        );
3612        let ring_ellipse_border = UploadRing::new(
3613            &device,
3614            "ring ellipse border",
3615            1 << 20,
3616            wgpu::BufferUsages::VERTEX
3617                | wgpu::BufferUsages::COPY_DST
3618                | wgpu::BufferUsages::COPY_SRC,
3619        );
3620        let ring_arc = UploadRing::new(
3621            &device,
3622            "ring arc",
3623            1 << 20,
3624            wgpu::BufferUsages::VERTEX
3625                | wgpu::BufferUsages::COPY_DST
3626                | wgpu::BufferUsages::COPY_SRC,
3627        );
3628        let ring_glyph_mask = UploadRing::new(
3629            &device,
3630            "ring glyph mask",
3631            1 << 20,
3632            wgpu::BufferUsages::VERTEX
3633                | wgpu::BufferUsages::COPY_DST
3634                | wgpu::BufferUsages::COPY_SRC,
3635        );
3636        let ring_glyph_color = UploadRing::new(
3637            &device,
3638            "ring glyph color",
3639            1 << 20,
3640            wgpu::BufferUsages::VERTEX
3641                | wgpu::BufferUsages::COPY_DST
3642                | wgpu::BufferUsages::COPY_SRC,
3643        );
3644        let ring_slug = UploadRing::new(
3645            &device,
3646            "ring slug",
3647            1 << 22,
3648            wgpu::BufferUsages::VERTEX
3649                | wgpu::BufferUsages::COPY_DST
3650                | wgpu::BufferUsages::COPY_SRC,
3651        );
3652        let ring_clip = UploadRing::new(
3653            &device,
3654            "ring clip",
3655            1 << 16,
3656            wgpu::BufferUsages::VERTEX
3657                | wgpu::BufferUsages::COPY_DST
3658                | wgpu::BufferUsages::COPY_SRC,
3659        );
3660        let blend_ring = UploadRing::new(
3661            &device,
3662            "ring blend",
3663            1 << 16,
3664            wgpu::BufferUsages::VERTEX
3665                | wgpu::BufferUsages::COPY_DST
3666                | wgpu::BufferUsages::COPY_SRC,
3667        );
3668        let ring_projective = UploadRing::new(
3669            &device,
3670            "ring projective",
3671            1 << 16,
3672            wgpu::BufferUsages::VERTEX
3673                | wgpu::BufferUsages::COPY_DST
3674                | wgpu::BufferUsages::COPY_SRC,
3675        );
3676        let ring_nv12 = UploadRing::new(
3677            &device,
3678            "ring nv12",
3679            1 << 20,
3680            wgpu::BufferUsages::VERTEX
3681                | wgpu::BufferUsages::COPY_DST
3682                | wgpu::BufferUsages::COPY_SRC,
3683        );
3684        // Placeholder textures
3685        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
3686            label: Some("temp ds"),
3687            size: wgpu::Extent3d {
3688                width: 1,
3689                height: 1,
3690                depth_or_array_layers: 1,
3691            },
3692            mip_level_count: 1,
3693            sample_count: 1,
3694            dimension: wgpu::TextureDimension::D2,
3695            format: wgpu::TextureFormat::Depth24PlusStencil8,
3696            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3697            view_formats: &[],
3698        });
3699        let depth_stencil_view =
3700            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
3701
3702        let mut renderer = WgpuSceneRenderer {
3703            device,
3704            queue,
3705            output_format,
3706            surface_format: output_format,
3707            output_width: 0,
3708            output_height: 0,
3709            pixels_per_point: 1.0,
3710
3711            surface_pipes,
3712            working_space_pipes,
3713            layer_pipes,
3714            working_space_layer_pipes,
3715
3716            rects: InstancedPipe::new(ring_rect),
3717            borders: InstancedPipe::new(ring_border),
3718            ellipses: InstancedPipe::new(ring_ellipse),
3719            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
3720            arcs: InstancedPipe::new(ring_arc),
3721            glyph_mask: InstancedPipe::new(ring_glyph_mask),
3722            glyph_color: InstancedPipe::new(ring_glyph_color),
3723
3724            text_bind_layout,
3725
3726            image_bind_layout_rgba,
3727            image_bind_layout_nv12,
3728            image_sampler,
3729            image_sampler_nearest,
3730            image_sampler_repeat,
3731            image_sampler_repeat_nearest,
3732            layer_sampler,
3733            layer_sampler_linear,
3734
3735            blur_ring,
3736
3737            slug_enabled,
3738            slug_ring: ring_slug,
3739            slug_cache: slug::GlyphSlugCache::new(),
3740            slug_draw_cache: HashMap::new(),
3741            slug_draw_cache_bytes_total: 0,
3742
3743            clip_ring: ring_clip,
3744
3745            nv12: InstancedPipe::new(ring_nv12),
3746
3747            mesh_cache: HashMap::new(),
3748            mesh_cache_bytes_total: 0,
3749            mesh_uniform_buf,
3750            mesh_bind,
3751            mesh_uniform_head: 0,
3752            mesh_uniform_alignment,
3753            mesh_uniform_cap,
3754            mesh_instance_buf,
3755            mesh_instance_head: 0,
3756            mesh_instance_cap,
3757
3758            projective_ring: ring_projective,
3759            blend_ring,
3760            flatten_layer_ids: Vec::new(),
3761            blur_scratch_ids: HashMap::new(),
3762            blur_scratch_next: 0,
3763            blend_snapshots: std::collections::HashMap::new(),
3764            blend_snapshot_pool: HashMap::new(),
3765            blend_copies: Vec::new(),
3766
3767            msaa_samples,
3768            working_space_msaa_samples,
3769            depth_stencil_tex,
3770            depth_stencil_view,
3771            msaa_tex: None,
3772            msaa_view: None,
3773            ws_msaa_tex: None,
3774            ws_msaa_view: None,
3775            surface_resolve_tex: None,
3776            surface_resolve_view: None,
3777            surface_resolve_bytes: 0,
3778            msaa_bytes: 0,
3779            ws_msaa_bytes: 0,
3780            depth_stencil_bytes: 0,
3781            working_space_bytes: 0,
3782            gpu_budget_bytes: MAX_GPU_RESOURCE_BYTES,
3783            globals_bind,
3784            globals_buf,
3785            globals_staging,
3786            globals_staging_cap,
3787
3788            atlas_mask,
3789            atlas_color,
3790            atlas_mask_bind,
3791            atlas_color_bind,
3792            atlas_generation: repose_text::font_generation(),
3793            atlas_mask_pending: AtlasPending::default(),
3794            atlas_color_pending: AtlasPending::default(),
3795            atlas_last_compact_frame: 0,
3796            atlas_mask_failures: HashSet::new(),
3797            atlas_color_failures: HashSet::new(),
3798            atlas_mask_deferred: HashSet::new(),
3799            atlas_color_deferred: HashSet::new(),
3800            glyph_outline_cache: HashMap::new(),
3801            legacy_shaped_text_cache: HashMap::new(),
3802
3803            next_image_handle: 1,
3804            images: HashMap::new(),
3805            retained: HashMap::new(),
3806            retained_bytes_total: 0,
3807
3808            next_coverage_handle: 1,
3809            coverages: HashMap::new(),
3810
3811            frame_index: 0,
3812            cache_touch_clock: 0,
3813            image_bytes_total: 0,
3814            image_evict_after_frames: 600,         // ~10s @ 60fps
3815            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
3816            layer_pool: HashMap::new(),
3817            producer_layer_ids: HashSet::new(),
3818            layer_bytes_total: 0,
3819            transient_layer_bytes_total: 0,
3820
3821            working_space: false,
3822            ws_tex: None,
3823            ws_view: None,
3824            ws_bind: None,
3825            display_pipeline: None,
3826            display_layout: None,
3827
3828            callback_resources: CallbackResources::default(),
3829            callback_scoped_resources: HashMap::new(),
3830            callback_scope_uses: HashMap::new(),
3831            callback_scope_payloads: HashMap::new(),
3832            callback_scope_clock: 0,
3833            blend_snapshot_bytes_total: 0,
3834            frame_active: false,
3835            last_render_error: None,
3836        };
3837
3838        renderer.recreate_msaa_and_depth_stencil();
3839        Ok(renderer)
3840    }
3841}
3842
3843/// wgpu backend names accepted by [`parse_backends`], mapped from the
3844/// canonical [`wgpu::Backend::to_str`] spelling plus the aliases people
3845/// actually type.
3846#[cfg(not(target_arch = "wasm32"))]
3847fn backend_by_name(name: &str) -> Option<wgpu::Backend> {
3848    match name {
3849        "gl" | "opengl" | "opengles" | "webgl" => Some(wgpu::Backend::Gl),
3850        "webgpu" | "browser-webgpu" => Some(wgpu::Backend::BrowserWebGpu),
3851        "metal" => Some(wgpu::Backend::Metal),
3852        "vulkan" => Some(wgpu::Backend::Vulkan),
3853        "dx12" | "d3d12" => Some(wgpu::Backend::Dx12),
3854        _ => None,
3855    }
3856}
3857
3858/// Parse a comma/space separated wgpu backend list such as `gl` or
3859/// `vulkan,gl`. Unknown names are dropped with a warning rather than
3860/// failing the whole spec, and `None` means "nothing valid was requested".
3861#[cfg(not(target_arch = "wasm32"))]
3862pub fn parse_backends(spec: &str) -> Option<wgpu::Backends> {
3863    let mut bits = wgpu::Backends::empty();
3864    for name in spec.split([',', ' ', '+']).filter(|n| !n.is_empty()) {
3865        match backend_by_name(&name.to_ascii_lowercase()) {
3866            Some(backend) => bits |= wgpu::Backends::from(backend),
3867            None => log::warn!(
3868                "REPOSE_WGPU_BACKENDS: ignoring unknown backend {name:?}; \
3869                 expected one of noop, vulkan, metal, dx12, gl, webgpu"
3870            ),
3871        }
3872    }
3873    (!bits.is_empty()).then_some(bits)
3874}
3875
3876#[cfg(feature = "winit-surface")]
3877pub(crate) async fn surface_instance(
3878    display: Option<winit::event_loop::OwnedDisplayHandle>,
3879) -> (Instance, wgpu::Backends) {
3880    // The display handle is what lets wgpu's GLES backend reach a real
3881    // platform surface. Without it wgpu falls back to a surfaceless/legacy-EGL
3882    // display, and on Wayland the GL adapter is then rejected outright as
3883    // surface-incompatible, which kills the OpenGL fallback entirely.
3884    let mut desc = match display {
3885        Some(handle) => wgpu::InstanceDescriptor::new_with_display_handle(Box::new(handle)),
3886        None => wgpu::InstanceDescriptor::new_without_display_handle(),
3887    };
3888    if cfg!(target_arch = "wasm32") {
3889        desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
3890    }
3891    #[cfg(not(target_arch = "wasm32"))]
3892    if let Ok(spec) = std::env::var("REPOSE_WGPU_BACKENDS")
3893        && let Some(bits) = parse_backends(&spec)
3894    {
3895        log::info!("REPOSE_WGPU_BACKENDS={spec} -> {bits:?}");
3896        desc.backends = bits;
3897    }
3898
3899    let backends = desc.backends;
3900    let instance = if cfg!(target_arch = "wasm32") {
3901        wgpu::util::new_instance_with_webgpu_detection(desc).await
3902    } else {
3903        wgpu::Instance::new(desc)
3904    };
3905    (instance, backends)
3906}
3907
3908impl WgpuSurfaceBackend {
3909    #[cfg(feature = "winit-surface")]
3910    pub async fn new_async(
3911        window: Arc<winit::window::Window>,
3912    ) -> anyhow::Result<WgpuSurfaceBackend> {
3913        Self::new_async_with_options(window, None, 4, PresentModePref::Auto).await
3914    }
3915
3916    /// Create a windowed surface backend, honoring the requested MSAA sample
3917    /// count (falling back to the largest supported count <= `msaa_samples`).
3918    #[cfg(feature = "winit-surface")]
3919    pub async fn new_async_with_msaa(
3920        window: Arc<winit::window::Window>,
3921        msaa_samples: u32,
3922    ) -> anyhow::Result<WgpuSurfaceBackend> {
3923        Self::new_async_with_options(window, None, msaa_samples, PresentModePref::Auto).await
3924    }
3925
3926    /// Create a windowed surface backend, honoring the requested MSAA sample
3927    /// count and present-mode preference.
3928    ///
3929    /// `display` must be the event loop's owned display handle on native
3930    /// platforms, otherwise the GL backend cannot present (see
3931    /// [`surface_instance`]).
3932    #[cfg(feature = "winit-surface")]
3933    pub async fn new_async_with_options(
3934        window: Arc<winit::window::Window>,
3935        display: Option<winit::event_loop::OwnedDisplayHandle>,
3936        msaa_samples: u32,
3937        present_mode: PresentModePref,
3938    ) -> anyhow::Result<WgpuSurfaceBackend> {
3939        let (instance, backends) = surface_instance(display).await;
3940
3941        let surface = instance.create_surface(window.clone())?;
3942
3943        let adapter = instance
3944            .request_adapter(&wgpu::RequestAdapterOptions {
3945                power_preference: wgpu::PowerPreference::HighPerformance,
3946                compatible_surface: Some(&surface),
3947                force_fallback_adapter: false,
3948                apply_limit_buckets: false,
3949            })
3950            .await
3951            .map_err(|e| {
3952                anyhow::anyhow!("no adapter for this window (enabled backends {backends:?}): {e:?}")
3953            })?;
3954        log::info!(
3955            "wgpu adapter: {} backend={:?} driver={:?} type={:?}",
3956            adapter.get_info().name,
3957            adapter.get_info().backend,
3958            adapter.get_info().driver,
3959            adapter.get_info().device_type,
3960        );
3961
3962        let limits = adapter.limits();
3963
3964        let features = {
3965            let available = adapter.features();
3966            let mut features = wgpu::Features::empty();
3967            if available.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) {
3968                features |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
3969            }
3970            #[cfg(target_os = "linux")]
3971            {
3972                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
3973                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
3974                }
3975                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
3976                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
3977                }
3978            }
3979            features
3980        };
3981
3982        let (device, queue) = adapter
3983            .request_device(&wgpu::DeviceDescriptor {
3984                label: Some("repose-rs device"),
3985                required_features: features,
3986                required_limits: limits,
3987                experimental_features: wgpu::ExperimentalFeatures::disabled(),
3988                memory_hints: wgpu::MemoryHints::default(),
3989                trace: wgpu::Trace::Off,
3990            })
3991            .await
3992            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
3993
3994        let device_lost = Arc::new(Mutex::new(None));
3995        {
3996            let device_lost = device_lost.clone();
3997            device.set_device_lost_callback(move |reason, message| {
3998                log::error!("wgpu device lost ({reason:?}): {message}");
3999                if let Ok(mut slot) = device_lost.lock() {
4000                    *slot = Some((reason, message));
4001                }
4002            });
4003        }
4004
4005        let size = window.inner_size();
4006
4007        let caps = surface.get_capabilities(&adapter);
4008
4009        let Some(format) = caps.formats.first().copied() else {
4010            anyhow::bail!(
4011                "surface exposes no presentable formats (adapter {})",
4012                adapter.get_info().name
4013            );
4014        };
4015
4016        let (format, view_format) = if cfg!(target_arch = "wasm32")
4017            && adapter
4018                .get_downlevel_capabilities()
4019                .flags
4020                .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS)
4021        {
4022            let non_srgb = caps
4023                .formats
4024                .iter()
4025                .copied()
4026                .find(|f| !f.is_srgb())
4027                .unwrap_or(format);
4028            (non_srgb, Some(non_srgb.add_srgb_suffix()))
4029        } else if cfg!(target_arch = "wasm32") {
4030            let fmt = caps
4031                .formats
4032                .iter()
4033                .copied()
4034                .find(|f| f.is_srgb())
4035                .unwrap_or(format);
4036            (fmt, None)
4037        } else {
4038            let fmt = caps
4039                .formats
4040                .iter()
4041                .copied()
4042                .find(|f| f.is_srgb())
4043                .unwrap_or(format);
4044            (fmt, None)
4045        };
4046
4047        let present_mode = pick_present_mode(&caps, present_mode);
4048        let alpha_mode = *caps
4049            .alpha_modes
4050            .first()
4051            .ok_or_else(|| anyhow::anyhow!("surface exposes no alpha modes"))?;
4052
4053        let render_format = view_format.unwrap_or(format);
4054        let msaa_samples = pick_surface_msaa(&adapter, render_format, msaa_samples);
4055        let mut renderer = WgpuSceneRenderer::try_from_device_with_adapter(
4056            &adapter,
4057            device,
4058            queue,
4059            render_format,
4060            msaa_samples,
4061        )?
4062        .with_surface_format(format)?;
4063        let (fw, fh) = clamp_output_size(
4064            size.width,
4065            size.height,
4066            renderer.device.limits().max_texture_dimension_2d,
4067        );
4068        renderer.resize(fw, fh);
4069
4070        let view_formats = view_format.into_iter().collect::<Vec<_>>();
4071
4072        // `COPY_SRC` lets an isolated blend snapshot the backdrop straight from
4073        // the swapchain when there is no multisample resolve target to copy
4074        // from. Canvas textures may not be storage-bound, but copying from them
4075        // is allowed. Not every backend grants it - GL surfaces are
4076        // render-target only - and the snapshot path already skips a source
4077        // without `COPY_SRC`, so request the intersection rather than making
4078        // `configure` fail and taking down every backend that lacks the usage.
4079        let wanted_usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
4080        let surface_usage = wanted_usage & caps.usages;
4081        if surface_usage != wanted_usage {
4082            log::warn!(
4083                "surface {format:?} cannot be used as {:?}; swapchain-sourced blend snapshots \
4084                 are skipped on this backend",
4085                wanted_usage - surface_usage
4086            );
4087        }
4088        let config = wgpu::SurfaceConfiguration {
4089            usage: surface_usage,
4090            format,
4091            width: fw.max(1),
4092            height: fh.max(1),
4093            present_mode,
4094            alpha_mode,
4095            color_space: wgpu::SurfaceColorSpace::Auto,
4096            view_formats,
4097            desired_maximum_frame_latency: 1,
4098        };
4099        surface.configure(&renderer.device, &config);
4100
4101        Ok(WgpuSurfaceBackend {
4102            #[cfg(feature = "winit-surface")]
4103            instance: Some(instance),
4104            #[cfg(feature = "winit-surface")]
4105            window: Some(window),
4106            surface: Some(surface),
4107            surface_config: Some(config),
4108            pending_reconfigure: false,
4109            renderer,
4110            device_lost,
4111        })
4112    }
4113
4114    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4115    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4116        pollster::block_on(Self::new_async(window))
4117    }
4118
4119    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4120    pub fn new_with_msaa(
4121        window: Arc<winit::window::Window>,
4122        msaa_samples: u32,
4123    ) -> anyhow::Result<WgpuSurfaceBackend> {
4124        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
4125    }
4126
4127    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4128    pub fn new_with_options(
4129        window: Arc<winit::window::Window>,
4130        display: Option<winit::event_loop::OwnedDisplayHandle>,
4131        msaa_samples: u32,
4132        present_mode: PresentModePref,
4133    ) -> anyhow::Result<WgpuSurfaceBackend> {
4134        pollster::block_on(Self::new_async_with_options(
4135            window,
4136            display,
4137            msaa_samples,
4138            present_mode,
4139        ))
4140    }
4141
4142    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4143    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4144        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
4145    }
4146
4147    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4148    pub fn new_with_msaa(
4149        _window: Arc<winit::window::Window>,
4150        _msaa_samples: u32,
4151    ) -> anyhow::Result<WgpuSurfaceBackend> {
4152        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
4153    }
4154
4155    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4156    pub fn new_with_options(
4157        _window: Arc<winit::window::Window>,
4158        _display: Option<winit::event_loop::OwnedDisplayHandle>,
4159        _msaa_samples: u32,
4160        _present_mode: PresentModePref,
4161    ) -> anyhow::Result<WgpuSurfaceBackend> {
4162        anyhow::bail!(
4163            "Use WgpuSurfaceBackend::new_async_with_options(window, display, msaa, mode).await on wasm32"
4164        )
4165    }
4166
4167    /// Reason the device was lost, consumed once. `None` while healthy and
4168    /// after an ordinary `Destroyed`, which is what dropping this backend
4169    /// produces; runners use this to tell a crash/reset apart from teardown
4170    /// and rebuild the backend.
4171    pub fn take_device_lost(&self) -> Option<String> {
4172        let (reason, message) = self.device_lost.lock().ok()?.take()?;
4173        (reason != wgpu::DeviceLostReason::Destroyed).then_some(message)
4174    }
4175}
4176
4177/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
4178/// Fifo-first selection when the preferred mode is unavailable.
4179#[cfg(feature = "winit-surface")]
4180fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
4181    let auto = || {
4182        caps.present_modes
4183            .iter()
4184            .copied()
4185            .find(|m| *m == wgpu::PresentMode::Fifo)
4186            .or_else(|| {
4187                caps.present_modes
4188                    .iter()
4189                    .copied()
4190                    .find(|m| *m == wgpu::PresentMode::Mailbox)
4191            })
4192            .unwrap_or(wgpu::PresentMode::Immediate)
4193    };
4194    match pref {
4195        PresentModePref::Auto => auto(),
4196        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
4197            wgpu::PresentMode::Fifo
4198        }
4199        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
4200            wgpu::PresentMode::Mailbox
4201        }
4202        PresentModePref::Immediate
4203            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
4204        {
4205            wgpu::PresentMode::Immediate
4206        }
4207        _ => auto(),
4208    }
4209}
4210
4211/// `wgpu` only accepts these sample counts; anything else (3, 5, 17, 0) is
4212/// a hard validation error at pipeline creation.
4213const VALID_SAMPLE_COUNTS: [u32; 5] = [1, 2, 4, 8, 16];
4214
4215fn validate_sample_count(label: &str, samples: u32) -> anyhow::Result<()> {
4216    if !VALID_SAMPLE_COUNTS.contains(&samples) {
4217        anyhow::bail!(
4218            "{label} must be one of {VALID_SAMPLE_COUNTS:?} (wgpu-supported sample counts), got {samples}"
4219        );
4220    }
4221    Ok(())
4222}
4223
4224/// Every UI pipeline writes `output_format` with premultiplied-alpha
4225/// blending, so those two properties have to hold before any pipeline is
4226/// built. Multisample resolve is only required when more than one sample is
4227/// actually in use: a format that is renderable and blendable but cannot
4228/// resolve is fine at x1, which is what [`pick_surface_msaa`] falls back to.
4229fn validate_render_target_format(
4230    adapter: &wgpu::Adapter,
4231    output_format: wgpu::TextureFormat,
4232    samples: u32,
4233) -> anyhow::Result<()> {
4234    let features = adapter.get_texture_format_features(output_format);
4235    let flags = features.flags;
4236    for (needed, what) in [
4237        (wgpu::TextureUsages::RENDER_ATTACHMENT, "render attachment"),
4238        (wgpu::TextureUsages::TEXTURE_BINDING, "texture binding"),
4239    ] {
4240        if !features.allowed_usages.contains(needed) {
4241            anyhow::bail!("{output_format:?} cannot be used as a {what}");
4242        }
4243    }
4244    if !flags.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) {
4245        anyhow::bail!("{output_format:?} cannot be blended into");
4246    }
4247    if samples > 1 && !flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE) {
4248        anyhow::bail!(
4249            "{output_format:?} cannot resolve the requested {samples}x multisampled attachment"
4250        );
4251    }
4252    // `set_working_space` renders into Rgba16Float and resolves into it.
4253    let ws = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4254    if !ws
4255        .allowed_usages
4256        .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4257        || !ws
4258            .allowed_usages
4259            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4260    {
4261        anyhow::bail!(
4262            "{:?} cannot be used for linear working-space rendering",
4263            wgpu::TextureFormat::Rgba16Float
4264        );
4265    }
4266    Ok(())
4267}
4268
4269/// Pick the MSAA sample count for the surface pass, honoring `requested` and
4270/// falling back to the largest supported count <= it.
4271pub fn pick_surface_msaa(
4272    adapter: &wgpu::Adapter,
4273    format: wgpu::TextureFormat,
4274    requested: u32,
4275) -> u32 {
4276    pick_surface_msaa_for_mode(adapter, format, requested, false)
4277}
4278
4279pub fn pick_surface_msaa_for_mode(
4280    adapter: &wgpu::Adapter,
4281    format: wgpu::TextureFormat,
4282    requested: u32,
4283    working_space: bool,
4284) -> u32 {
4285    let requested = requested.max(1);
4286    let color_feat = adapter.get_texture_format_features(format);
4287    let working_space_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4288    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
4289    let supported = |n: u32| {
4290        color_feat
4291            .allowed_usages
4292            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4293            && color_feat.flags.sample_count_supported(n)
4294            && (!working_space
4295                || (working_space_feat
4296                    .allowed_usages
4297                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4298                    && working_space_feat.flags.sample_count_supported(n)
4299                    && (n == 1
4300                        || working_space_feat
4301                            .flags
4302                            .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))))
4303            && (n == 1
4304                || color_feat
4305                    .flags
4306                    .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))
4307            && depth_feat
4308                .allowed_usages
4309                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4310            && depth_feat.flags.sample_count_supported(n)
4311    };
4312    let mut candidates = vec![requested];
4313    for n in [8, 4, 2, 1] {
4314        if n < requested {
4315            candidates.push(n);
4316        }
4317    }
4318    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
4319    if chosen != requested {
4320        log::info!("requested MSAA x{requested}, using x{chosen}");
4321    }
4322    chosen
4323}
4324
4325impl WgpuSceneRenderer {
4326    // Image API
4327
4328    pub fn set_image_from_bytes(
4329        &mut self,
4330        handle: u64,
4331        data: &[u8],
4332        srgb: bool,
4333    ) -> anyhow::Result<()> {
4334        validate_image_handle(handle)?;
4335        let max_dimension = self.device.limits().max_texture_dimension_2d;
4336        let (w, h, rgba) = decode_image_rgba8(data, max_dimension)?;
4337        self.set_image_rgba8(handle, w, h, &rgba, srgb)?;
4338        // Encoded uploads have no size at the call site, so publish it here for
4339        // layout - only once the pixels are actually on the GPU.
4340        repose_core::set_image_intrinsic_size(handle, w, h);
4341        Ok(())
4342    }
4343
4344    pub fn set_image_rgba8(
4345        &mut self,
4346        handle: u64,
4347        w: u32,
4348        h: u32,
4349        rgba: &[u8],
4350        srgb: bool,
4351    ) -> anyhow::Result<()> {
4352        validate_image_handle(handle)?;
4353        validate_texture_dimensions(&self.device, w, h)?;
4354        let expected = checked_image_bytes_usize(w, h, 4)?;
4355        if rgba.len() < expected {
4356            return Err(anyhow::anyhow!(
4357                "RGBA buffer too small: {} < {}",
4358                rgba.len(),
4359                expected
4360            ));
4361        }
4362
4363        let format = if srgb {
4364            wgpu::TextureFormat::Rgba8UnormSrgb
4365        } else {
4366            wgpu::TextureFormat::Rgba8Unorm
4367        };
4368
4369        let needs_recreate = match self.images.get(&handle) {
4370            Some(ImageTex::Rgba {
4371                w: cw,
4372                h: ch,
4373                format: cf,
4374                ..
4375            }) => *cw != w || *ch != h || *cf != format,
4376            _ => true,
4377        };
4378
4379        if needs_recreate {
4380            self.remove_image(handle);
4381
4382            let (tex, binds) = self.create_rgba_tex(w, h, format);
4383            let bytes = checked_image_bytes(w, h, 4)?;
4384            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4385
4386            self.images.insert(
4387                handle,
4388                ImageTex::Rgba {
4389                    tex,
4390                    binds,
4391                    w,
4392                    h,
4393                    format,
4394                    last_used_frame: self.frame_index,
4395                    bytes,
4396                },
4397            );
4398        }
4399
4400        if let Some(old) = self.retained.remove(&handle) {
4401            self.retained_bytes_total = self
4402                .retained_bytes_total
4403                .saturating_sub(old.rgba.len() as u64);
4404        }
4405        self.retained_bytes_total = self.retained_bytes_total.saturating_add(expected as u64);
4406        self.retained.insert(
4407            handle,
4408            RetainedImage {
4409                w,
4410                h,
4411                format,
4412                rgba: rgba[..expected].to_vec(),
4413                last_used_frame: self.frame_index,
4414            },
4415        );
4416        self.evict_retained_excess();
4417
4418        let tex = match self.images.get(&handle) {
4419            Some(ImageTex::Rgba { tex, .. }) => tex,
4420            _ => unreachable!(),
4421        };
4422
4423        self.queue.write_texture(
4424            wgpu::TexelCopyTextureInfo {
4425                texture: tex,
4426                mip_level: 0,
4427                origin: wgpu::Origin3d::ZERO,
4428                aspect: wgpu::TextureAspect::All,
4429            },
4430            &rgba[..expected],
4431            wgpu::TexelCopyBufferLayout {
4432                offset: 0,
4433                bytes_per_row: Some(4 * w),
4434                rows_per_image: Some(h),
4435            },
4436            wgpu::Extent3d {
4437                width: w,
4438                height: h,
4439                depth_or_array_layers: 1,
4440            },
4441        );
4442
4443        // Ensure budget limits
4444        self.evict_budget_excess();
4445
4446        Ok(())
4447    }
4448
4449    /// Create (but do not populate) the GPU texture, view and bind group for an
4450    /// RGBA image. Pixels are written separately via `write_texture`.
4451    fn create_image_binds(
4452        &self,
4453        view: &wgpu::TextureView,
4454        label: &str,
4455        sampler: Option<&wgpu::Sampler>,
4456    ) -> ImageBinds {
4457        let make = |name: &str, sampler: &wgpu::Sampler| {
4458            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4459                label: Some(name),
4460                layout: &self.image_bind_layout_rgba,
4461                entries: &[
4462                    wgpu::BindGroupEntry {
4463                        binding: 0,
4464                        resource: wgpu::BindingResource::TextureView(view),
4465                    },
4466                    wgpu::BindGroupEntry {
4467                        binding: 1,
4468                        resource: wgpu::BindingResource::Sampler(sampler),
4469                    },
4470                ],
4471            })
4472        };
4473        match sampler {
4474            Some(sampler) => {
4475                let bind = make(label, sampler);
4476                ImageBinds {
4477                    linear: make(label, sampler),
4478                    nearest: bind.clone(),
4479                    tile_linear: bind.clone(),
4480                    tile_nearest: bind,
4481                }
4482            }
4483            None => ImageBinds {
4484                linear: make(label, &self.image_sampler),
4485                nearest: make(label, &self.image_sampler_nearest),
4486                tile_linear: make(label, &self.image_sampler_repeat),
4487                tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4488            },
4489        }
4490    }
4491
4492    fn create_nv12_binds(
4493        &self,
4494        view_y: &wgpu::TextureView,
4495        view_uv: &wgpu::TextureView,
4496        yuv_buf: &wgpu::Buffer,
4497        label: &str,
4498    ) -> ImageBinds {
4499        let make = |name: &str, sampler: &wgpu::Sampler| {
4500            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4501                label: Some(name),
4502                layout: &self.image_bind_layout_nv12,
4503                entries: &[
4504                    wgpu::BindGroupEntry {
4505                        binding: 0,
4506                        resource: wgpu::BindingResource::TextureView(view_y),
4507                    },
4508                    wgpu::BindGroupEntry {
4509                        binding: 1,
4510                        resource: wgpu::BindingResource::TextureView(view_uv),
4511                    },
4512                    wgpu::BindGroupEntry {
4513                        binding: 2,
4514                        resource: wgpu::BindingResource::Sampler(sampler),
4515                    },
4516                    wgpu::BindGroupEntry {
4517                        binding: 3,
4518                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4519                            buffer: yuv_buf,
4520                            offset: 0,
4521                            size: None,
4522                        }),
4523                    },
4524                ],
4525            })
4526        };
4527        ImageBinds {
4528            linear: make(label, &self.image_sampler),
4529            nearest: make(label, &self.image_sampler_nearest),
4530            tile_linear: make(label, &self.image_sampler_repeat),
4531            tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4532        }
4533    }
4534
4535    fn create_rgba_tex(
4536        &self,
4537        w: u32,
4538        h: u32,
4539        format: wgpu::TextureFormat,
4540    ) -> (wgpu::Texture, ImageBinds) {
4541        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
4542            label: Some("user image rgba"),
4543            size: wgpu::Extent3d {
4544                width: w,
4545                height: h,
4546                depth_or_array_layers: 1,
4547            },
4548            mip_level_count: 1,
4549            sample_count: 1,
4550            dimension: wgpu::TextureDimension::D2,
4551            format,
4552            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4553            view_formats: &[],
4554        });
4555        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
4556        let binds = self.create_image_binds(&view, "image bind rgba", None);
4557        (tex, binds)
4558    }
4559
4560    /// Register an externally-created `wgpu::TextureView` as an image (zero-copy).
4561    fn validate_native_texture_view(
4562        &self,
4563        view: &wgpu::TextureView,
4564        width: u32,
4565        height: u32,
4566    ) -> anyhow::Result<()> {
4567        if width == 0 || height == 0 {
4568            anyhow::bail!("native texture dimensions must be non-zero");
4569        }
4570        let texture = view.texture();
4571        if texture.sample_count() != 1
4572            || texture.dimension() != wgpu::TextureDimension::D2
4573            || texture.depth_or_array_layers() != 1
4574            || texture.mip_level_count() == 0
4575        {
4576            anyhow::bail!("native texture must be a single-sample 2D texture with mip levels");
4577        }
4578        if width > self.device.limits().max_texture_dimension_2d
4579            || height > self.device.limits().max_texture_dimension_2d
4580        {
4581            anyhow::bail!("native texture dimensions exceed the device limit");
4582        }
4583        if !texture
4584            .usage()
4585            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4586        {
4587            anyhow::bail!("native texture lacks TEXTURE_BINDING usage");
4588        }
4589        if !native_format_supported(texture.format(), self.device.features()) {
4590            anyhow::bail!(
4591                "native texture format {:?} is not a filterable alpha color format",
4592                texture.format()
4593            );
4594        }
4595        if texture.width() < width || texture.height() < height {
4596            anyhow::bail!(
4597                "native texture is {}x{}, smaller than requested {width}x{height}",
4598                texture.width(),
4599                texture.height()
4600            );
4601        }
4602        Ok(())
4603    }
4604
4605    pub fn register_native_texture(
4606        &mut self,
4607        view: &wgpu::TextureView,
4608        width: u32,
4609        height: u32,
4610    ) -> u64 {
4611        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4612            log::warn!("register_native_texture: {error:#}");
4613            return 0;
4614        }
4615        let handle = self.next_image_handle;
4616        self.next_image_handle += 1;
4617        let binds = self.create_image_binds(view, "user native image", None);
4618        self.images.insert(
4619            handle,
4620            ImageTex::User {
4621                binds,
4622                w: width,
4623                h: height,
4624                last_used_frame: self.frame_index,
4625                bytes: 0,
4626            },
4627        );
4628        handle
4629    }
4630
4631    /// Like `register_native_texture` but with custom sampler descriptor.
4632    pub fn register_native_texture_with_sampler(
4633        &mut self,
4634        view: &wgpu::TextureView,
4635        sampler_desc: wgpu::SamplerDescriptor<'_>,
4636        width: u32,
4637        height: u32,
4638    ) -> u64 {
4639        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4640            log::warn!("register_native_texture: {error:#}");
4641            return 0;
4642        }
4643        if let Err(error) = validate_sampler_descriptor(&self.device, &sampler_desc) {
4644            log::warn!("register_native_texture: {error:#}");
4645            return 0;
4646        }
4647        let handle = self.next_image_handle;
4648        self.next_image_handle += 1;
4649        let sampler = self.device.create_sampler(&sampler_desc);
4650        let binds = self.create_image_binds(view, "user native image sampleropts", Some(&sampler));
4651        self.images.insert(
4652            handle,
4653            ImageTex::User {
4654                binds,
4655                w: width,
4656                h: height,
4657                last_used_frame: self.frame_index,
4658                bytes: 0,
4659            },
4660        );
4661        handle
4662    }
4663
4664    /// Update an existing native texture handle with a new view (reuse handle).
4665    pub fn update_native_texture(&mut self, handle: u64, view: &wgpu::TextureView) {
4666        if handle == 0 {
4667            log::warn!("update_native_texture: reserved handle");
4668            return;
4669        }
4670        let Some((w, h)) = self.images.get(&handle).and_then(|entry| match entry {
4671            ImageTex::User { w, h, .. } => Some((*w, *h)),
4672            _ => None,
4673        }) else {
4674            log::warn!("update_native_texture: handle {handle} is not a native image");
4675            return;
4676        };
4677        if let Err(error) = self.validate_native_texture_view(view, w, h) {
4678            log::warn!("update_native_texture: {error:#}");
4679            return;
4680        }
4681        let binds = self.create_image_binds(view, "user native image update", None);
4682        if let Some(entry) = self.images.get_mut(&handle) {
4683            *entry = ImageTex::User {
4684                binds,
4685                w,
4686                h,
4687                last_used_frame: self.frame_index,
4688                bytes: 0,
4689            };
4690        }
4691    }
4692
4693    pub fn set_image_nv12(
4694        &mut self,
4695        handle: u64,
4696        w: u32,
4697        h: u32,
4698        y: &[u8],
4699        uv: &[u8],
4700        color_info: ColorInfo,
4701    ) -> anyhow::Result<()> {
4702        validate_image_handle(handle)?;
4703        validate_texture_dimensions(&self.device, w, h)?;
4704        if self
4705            .images
4706            .get(&handle)
4707            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4708        {
4709            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4710        }
4711        let y_expected = checked_image_bytes_usize(w, h, 1)?;
4712        let uv_w = w.div_ceil(2);
4713        let uv_h = h.div_ceil(2);
4714        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 2)?;
4715
4716        if y.len() < y_expected {
4717            return Err(anyhow::anyhow!("Y plane too small"));
4718        }
4719        if uv.len() < uv_expected {
4720            return Err(anyhow::anyhow!("UV plane too small"));
4721        }
4722
4723        let needs_recreate = match self.images.get(&handle) {
4724            Some(ImageTex::Nv12 {
4725                w: ww,
4726                h: hh,
4727                pixel_format,
4728                ..
4729            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::Nv12,
4730            _ => true,
4731        };
4732
4733        // Compute the YUV->RGB transform on the CPU.
4734        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::Nv12);
4735
4736        if needs_recreate {
4737            self.remove_image(handle);
4738
4739            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4740                label: Some("nv12 Y"),
4741                size: wgpu::Extent3d {
4742                    width: w,
4743                    height: h,
4744                    depth_or_array_layers: 1,
4745                },
4746                mip_level_count: 1,
4747                sample_count: 1,
4748                dimension: wgpu::TextureDimension::D2,
4749                format: wgpu::TextureFormat::R8Unorm,
4750                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4751                view_formats: &[],
4752            });
4753            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4754
4755            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4756                label: Some("nv12 UV"),
4757                size: wgpu::Extent3d {
4758                    width: uv_w,
4759                    height: uv_h,
4760                    depth_or_array_layers: 1,
4761                },
4762                mip_level_count: 1,
4763                sample_count: 1,
4764                dimension: wgpu::TextureDimension::D2,
4765                format: wgpu::TextureFormat::Rg8Unorm,
4766                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4767                view_formats: &[],
4768            });
4769            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4770
4771            // Create a uniform buffer for the YUV transform (per-image).
4772            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4773                label: Some("nv12 yuv transform"),
4774                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4775                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4776                mapped_at_creation: false,
4777            });
4778
4779            // Write initial transform.
4780            self.queue
4781                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4782
4783            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "nv12 bind");
4784
4785            let bytes = checked_image_bytes(w, h, 1)?
4786                .checked_add(checked_image_bytes(uv_w, uv_h, 2)?)
4787                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4788                .ok_or_else(|| anyhow::anyhow!("NV12 image byte size overflow"))?;
4789            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4790
4791            self.images.insert(
4792                handle,
4793                ImageTex::Nv12 {
4794                    tex_y,
4795                    tex_uv,
4796                    binds,
4797                    yuv_buf,
4798                    w,
4799                    h,
4800                    pixel_format: PixelFormat::Nv12,
4801                    fourcc: Some(NV12_FOURCC),
4802                    color_info,
4803                    external: false,
4804                    last_used_frame: self.frame_index,
4805                    bytes,
4806                },
4807            );
4808        } else {
4809            // Re-use existing textures; just update the YUV transform if needed.
4810            if let Some(ImageTex::Nv12 {
4811                yuv_buf,
4812                color_info: stored_color,
4813                last_used_frame,
4814                ..
4815            }) = self.images.get_mut(&handle)
4816            {
4817                *stored_color = color_info;
4818                *last_used_frame = self.frame_index;
4819                self.queue
4820                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4821            }
4822        }
4823
4824        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4825            Some(ImageTex::Nv12 {
4826                tex_y,
4827                tex_uv,
4828                binds,
4829                ..
4830            }) => (tex_y, tex_uv, binds),
4831            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
4832        };
4833
4834        self.queue.write_texture(
4835            wgpu::TexelCopyTextureInfo {
4836                texture: tex_y,
4837                mip_level: 0,
4838                origin: wgpu::Origin3d::ZERO,
4839                aspect: wgpu::TextureAspect::All,
4840            },
4841            &y[..y_expected],
4842            wgpu::TexelCopyBufferLayout {
4843                offset: 0,
4844                bytes_per_row: Some(w),
4845                rows_per_image: Some(h),
4846            },
4847            wgpu::Extent3d {
4848                width: w,
4849                height: h,
4850                depth_or_array_layers: 1,
4851            },
4852        );
4853
4854        self.queue.write_texture(
4855            wgpu::TexelCopyTextureInfo {
4856                texture: tex_uv,
4857                mip_level: 0,
4858                origin: wgpu::Origin3d::ZERO,
4859                aspect: wgpu::TextureAspect::All,
4860            },
4861            &uv[..uv_expected],
4862            wgpu::TexelCopyBufferLayout {
4863                offset: 0,
4864                bytes_per_row: Some(2 * uv_w),
4865                rows_per_image: Some(uv_h),
4866            },
4867            wgpu::Extent3d {
4868                width: uv_w,
4869                height: uv_h,
4870                depth_or_array_layers: 1,
4871            },
4872        );
4873
4874        self.evict_budget_excess();
4875        Ok(())
4876    }
4877
4878    pub fn set_image_planes(
4879        &mut self,
4880        handle: u64,
4881        w: u32,
4882        h: u32,
4883        pixel_format: PixelFormat,
4884        planes: &[&[u8]],
4885        color_info: ColorInfo,
4886    ) -> anyhow::Result<()> {
4887        validate_image_handle(handle)?;
4888        if planes.len() != pixel_format.num_planes() as usize {
4889            anyhow::bail!(
4890                "{} requires {} planes, got {}",
4891                match pixel_format {
4892                    PixelFormat::Nv12 => "NV12",
4893                    PixelFormat::P010 => "P010",
4894                    PixelFormat::I420 => "I420",
4895                    PixelFormat::I444 => "I444",
4896                    PixelFormat::Rgba => "RGBA",
4897                },
4898                pixel_format.num_planes(),
4899                planes.len()
4900            );
4901        }
4902        match pixel_format {
4903            PixelFormat::Nv12 => {
4904                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4905                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4906                self.set_image_nv12(handle, w, h, y, uv, color_info)
4907            }
4908            PixelFormat::P010 => {
4909                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4910                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4911                self.set_image_p010(handle, w, h, y, uv, color_info)
4912            }
4913            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
4914                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
4915            )),
4916            PixelFormat::Rgba => {
4917                let rgba = planes
4918                    .first()
4919                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
4920                self.set_image_rgba8(handle, w, h, rgba, false)
4921            }
4922        }
4923    }
4924
4925    fn set_image_p010(
4926        &mut self,
4927        handle: u64,
4928        w: u32,
4929        h: u32,
4930        y: &[u8],
4931        uv: &[u8],
4932        color_info: ColorInfo,
4933    ) -> anyhow::Result<()> {
4934        validate_image_handle(handle)?;
4935        validate_texture_dimensions(&self.device, w, h)?;
4936        if self
4937            .images
4938            .get(&handle)
4939            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4940        {
4941            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4942        }
4943        if !self
4944            .device
4945            .features()
4946            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
4947        {
4948            anyhow::bail!("P010 requires TEXTURE_FORMAT_16BIT_NORM");
4949        }
4950        let uv_w = w.div_ceil(2);
4951        let uv_h = h.div_ceil(2);
4952
4953        let y_expected = checked_image_bytes_usize(w, h, 2)?;
4954        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 4)?;
4955
4956        if y.len() < y_expected {
4957            return Err(anyhow::anyhow!("P010 Y plane too small"));
4958        }
4959        if uv.len() < uv_expected {
4960            return Err(anyhow::anyhow!("P010 UV plane too small"));
4961        }
4962
4963        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
4964        // abstracts the storage format so R16Unorm/Rg16Unorm are
4965        // filterable float textures just like R8Unorm/Rg8Unorm).
4966        let needs_recreate = match self.images.get(&handle) {
4967            Some(ImageTex::Nv12 {
4968                w: ww,
4969                h: hh,
4970                pixel_format,
4971                ..
4972            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::P010,
4973            _ => true,
4974        };
4975
4976        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::P010);
4977
4978        if needs_recreate {
4979            self.remove_image(handle);
4980
4981            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4982                label: Some("p010 Y"),
4983                size: wgpu::Extent3d {
4984                    width: w,
4985                    height: h,
4986                    depth_or_array_layers: 1,
4987                },
4988                mip_level_count: 1,
4989                sample_count: 1,
4990                dimension: wgpu::TextureDimension::D2,
4991                format: wgpu::TextureFormat::R16Unorm,
4992                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4993                view_formats: &[],
4994            });
4995            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4996
4997            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4998                label: Some("p010 UV"),
4999                size: wgpu::Extent3d {
5000                    width: uv_w,
5001                    height: uv_h,
5002                    depth_or_array_layers: 1,
5003                },
5004                mip_level_count: 1,
5005                sample_count: 1,
5006                dimension: wgpu::TextureDimension::D2,
5007                format: wgpu::TextureFormat::Rg16Unorm,
5008                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5009                view_formats: &[],
5010            });
5011            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5012
5013            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5014                label: Some("p010 yuv transform"),
5015                size: std::mem::size_of::<YuvTransformRaw>() as u64,
5016                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5017                mapped_at_creation: false,
5018            });
5019            self.queue
5020                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5021
5022            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "p010 bind");
5023
5024            let bytes = checked_image_bytes(w, h, 2)?
5025                .checked_add(checked_image_bytes(uv_w, uv_h, 4)?)
5026                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5027                .ok_or_else(|| anyhow::anyhow!("P010 image byte size overflow"))?;
5028            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5029
5030            self.images.insert(
5031                handle,
5032                ImageTex::Nv12 {
5033                    tex_y,
5034                    tex_uv,
5035                    binds,
5036                    yuv_buf,
5037                    w,
5038                    h,
5039                    pixel_format: PixelFormat::P010,
5040                    fourcc: Some(P010_FOURCC),
5041                    color_info,
5042                    external: false,
5043                    last_used_frame: self.frame_index,
5044                    bytes,
5045                },
5046            );
5047        } else {
5048            if let Some(ImageTex::Nv12 {
5049                yuv_buf,
5050                color_info: stored_color,
5051                last_used_frame,
5052                ..
5053            }) = self.images.get_mut(&handle)
5054            {
5055                *stored_color = color_info;
5056                *last_used_frame = self.frame_index;
5057                self.queue
5058                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5059            }
5060        }
5061
5062        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
5063            Some(ImageTex::Nv12 {
5064                tex_y,
5065                tex_uv,
5066                binds,
5067                ..
5068            }) => (tex_y, tex_uv, binds),
5069            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
5070        };
5071
5072        self.queue.write_texture(
5073            wgpu::TexelCopyTextureInfo {
5074                texture: tex_y,
5075                mip_level: 0,
5076                origin: wgpu::Origin3d::ZERO,
5077                aspect: wgpu::TextureAspect::All,
5078            },
5079            &y[..y_expected],
5080            wgpu::TexelCopyBufferLayout {
5081                offset: 0,
5082                bytes_per_row: Some(
5083                    w.checked_mul(2)
5084                        .ok_or_else(|| anyhow::anyhow!("P010 row size overflow"))?,
5085                ),
5086                rows_per_image: Some(h),
5087            },
5088            wgpu::Extent3d {
5089                width: w,
5090                height: h,
5091                depth_or_array_layers: 1,
5092            },
5093        );
5094        self.queue.write_texture(
5095            wgpu::TexelCopyTextureInfo {
5096                texture: tex_uv,
5097                mip_level: 0,
5098                origin: wgpu::Origin3d::ZERO,
5099                aspect: wgpu::TextureAspect::All,
5100            },
5101            &uv[..uv_expected],
5102            wgpu::TexelCopyBufferLayout {
5103                offset: 0,
5104                bytes_per_row: Some(
5105                    uv_w.checked_mul(4)
5106                        .ok_or_else(|| anyhow::anyhow!("P010 UV row size overflow"))?,
5107                ),
5108                rows_per_image: Some(uv_h),
5109            },
5110            wgpu::Extent3d {
5111                width: uv_w,
5112                height: uv_h,
5113                depth_or_array_layers: 1,
5114            },
5115        );
5116
5117        self.evict_budget_excess();
5118        Ok(())
5119    }
5120
5121    #[cfg(target_os = "linux")]
5122    pub fn set_image_dmabuf(
5123        &mut self,
5124        handle: u64,
5125        w: u32,
5126        h: u32,
5127        fds: Vec<std::os::unix::io::OwnedFd>,
5128        modifier: u64,
5129        strides: Vec<u32>,
5130        offsets: Vec<u64>,
5131        color_info: ColorInfo,
5132    ) -> anyhow::Result<()> {
5133        self.set_image_dmabuf_fourcc(
5134            handle,
5135            w,
5136            h,
5137            fds,
5138            NV12_FOURCC,
5139            modifier,
5140            strides,
5141            offsets,
5142            color_info,
5143        )
5144    }
5145
5146    #[cfg(target_os = "linux")]
5147    pub fn set_image_dmabuf_fourcc(
5148        &mut self,
5149        handle: u64,
5150        w: u32,
5151        h: u32,
5152        fds: Vec<std::os::unix::io::OwnedFd>,
5153        fourcc: u32,
5154        modifier: u64,
5155        strides: Vec<u32>,
5156        offsets: Vec<u64>,
5157        color_info: ColorInfo,
5158    ) -> anyhow::Result<()> {
5159        validate_image_handle(handle)?;
5160        checked_image_bytes(w, h, 1)?;
5161        if w == 0
5162            || h == 0
5163            || w > self.device.limits().max_texture_dimension_2d
5164            || h > self.device.limits().max_texture_dimension_2d
5165        {
5166            anyhow::bail!("DMA-BUF dimensions are outside the device texture limit");
5167        }
5168        if modifier == u64::MAX {
5169            anyhow::bail!("DMA-BUF modifier is invalid");
5170        }
5171        if fds.len() != 2 || strides.len() != 2 || offsets.len() != 2 {
5172            anyhow::bail!(
5173                "DMA-BUF import requires two fds, strides, and offsets (got {}, {}, {})",
5174                fds.len(),
5175                strides.len(),
5176                offsets.len()
5177            );
5178        }
5179        let pixel_format = match fourcc {
5180            0x3231_564e | 0x4e56_3132 => PixelFormat::Nv12,
5181            0x3031_3050 | 0x5030_3130 => PixelFormat::P010,
5182            _ => anyhow::bail!("unsupported DMA-BUF fourcc 0x{fourcc:08x}"),
5183        };
5184        if !self
5185            .device
5186            .features()
5187            .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF)
5188        {
5189            anyhow::bail!("DMA-BUF import requires VULKAN_EXTERNAL_MEMORY_DMA_BUF");
5190        }
5191        if pixel_format == PixelFormat::P010
5192            && !self
5193                .device
5194                .features()
5195                .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
5196        {
5197            anyhow::bail!("P010 DMA-BUF requires TEXTURE_FORMAT_16BIT_NORM");
5198        }
5199        let bytes_per_pixel = if pixel_format == PixelFormat::P010 {
5200            2
5201        } else {
5202            1
5203        };
5204        let uv_w = w.div_ceil(2);
5205        let uv_h = h.div_ceil(2);
5206        let y_file_size =
5207            validate_dmabuf_plane(&fds[0], offsets[0], strides[0], w, h, bytes_per_pixel)?;
5208        let uv_file_size = validate_dmabuf_plane(
5209            &fds[1],
5210            offsets[1],
5211            strides[1],
5212            uv_w,
5213            uv_h,
5214            bytes_per_pixel * 2,
5215        )?;
5216        let yuv_raw = make_yuv_transform_raw(color_info, pixel_format);
5217        let (y_format, uv_format) = if pixel_format == PixelFormat::P010 {
5218            (
5219                wgpu::TextureFormat::R16Unorm,
5220                wgpu::TextureFormat::Rg16Unorm,
5221            )
5222        } else {
5223            (wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::Rg8Unorm)
5224        };
5225        let hal_y_desc = wgpu::hal::TextureDescriptor {
5226            label: Some("dmabuf y"),
5227            size: wgpu::Extent3d {
5228                width: w,
5229                height: h,
5230                depth_or_array_layers: 1,
5231            },
5232            mip_level_count: 1,
5233            sample_count: 1,
5234            dimension: wgpu::TextureDimension::D2,
5235            format: y_format,
5236            usage: wgpu::wgt::TextureUses::RESOURCE,
5237            memory_flags: wgpu::hal::MemoryFlags::empty(),
5238            view_formats: vec![],
5239        };
5240        let hal_uv_desc = wgpu::hal::TextureDescriptor {
5241            label: Some("dmabuf uv"),
5242            size: wgpu::Extent3d {
5243                width: uv_w,
5244                height: uv_h,
5245                depth_or_array_layers: 1,
5246            },
5247            mip_level_count: 1,
5248            sample_count: 1,
5249            dimension: wgpu::TextureDimension::D2,
5250            format: uv_format,
5251            usage: wgpu::wgt::TextureUses::RESOURCE,
5252            memory_flags: wgpu::hal::MemoryFlags::empty(),
5253            view_formats: vec![],
5254        };
5255        let wgpu_y_desc = wgpu::TextureDescriptor {
5256            label: Some("dmabuf y"),
5257            size: hal_y_desc.size,
5258            mip_level_count: 1,
5259            sample_count: 1,
5260            dimension: wgpu::TextureDimension::D2,
5261            format: y_format,
5262            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5263            view_formats: &[],
5264        };
5265        let wgpu_uv_desc = wgpu::TextureDescriptor {
5266            label: Some("dmabuf uv"),
5267            size: hal_uv_desc.size,
5268            mip_level_count: 1,
5269            sample_count: 1,
5270            dimension: wgpu::TextureDimension::D2,
5271            format: uv_format,
5272            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5273            view_formats: &[],
5274        };
5275
5276        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
5277            let hal_guard = self
5278                .device
5279                .as_hal::<wgpu::hal::vulkan::Api>()
5280                .ok_or_else(|| anyhow::anyhow!("device is not Vulkan"))?;
5281            let mut fds = fds.into_iter();
5282            let y_fd = fds.next().expect("validated fd count");
5283            let uv_fd = fds.next().expect("validated fd count");
5284            let y_texture = hal_guard.texture_from_dmabuf_fd(
5285                y_fd,
5286                &hal_y_desc,
5287                modifier,
5288                strides[0] as u64,
5289                offsets[0],
5290            );
5291            let y_texture =
5292                y_texture.map_err(|error| anyhow::anyhow!("import Y DMA-BUF: {error:?}"))?;
5293            let uv_texture = hal_guard.texture_from_dmabuf_fd(
5294                uv_fd,
5295                &hal_uv_desc,
5296                modifier,
5297                strides[1] as u64,
5298                offsets[1],
5299            );
5300            let uv_texture =
5301                uv_texture.map_err(|error| anyhow::anyhow!("import UV DMA-BUF: {error:?}"))?;
5302            drop(hal_guard);
5303            let tex_y = self
5304                .device
5305                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5306                    y_texture,
5307                    &wgpu_y_desc,
5308                    wgpu::wgt::TextureUses::UNINITIALIZED,
5309                );
5310            let tex_uv = self
5311                .device
5312                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5313                    uv_texture,
5314                    &wgpu_uv_desc,
5315                    wgpu::wgt::TextureUses::UNINITIALIZED,
5316                );
5317            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
5318            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5319            (tex_y, view_y, tex_uv, view_uv)
5320        };
5321
5322        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5323            label: Some("dmabuf yuv transform"),
5324            size: std::mem::size_of::<YuvTransformRaw>() as u64,
5325            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5326            mapped_at_creation: false,
5327        });
5328        self.queue
5329            .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5330        let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "dmabuf yuv bind");
5331        let bytes = y_file_size
5332            .checked_add(uv_file_size)
5333            .and_then(|value| value.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5334            .ok_or_else(|| anyhow::anyhow!("DMA-BUF byte size overflow"))?;
5335        self.evict_budget_excess();
5336        let replaced_bytes = self
5337            .images
5338            .get(&handle)
5339            .map(|image| match image {
5340                ImageTex::Rgba { bytes, .. }
5341                | ImageTex::Nv12 { bytes, .. }
5342                | ImageTex::User { bytes, .. } => *bytes,
5343            })
5344            .unwrap_or(0);
5345        let projected = self
5346            .image_bytes_total
5347            .saturating_sub(replaced_bytes)
5348            .checked_add(bytes)
5349            .ok_or_else(|| anyhow::anyhow!("DMA-BUF image budget overflow"))?;
5350        if projected > self.image_budget_bytes {
5351            anyhow::bail!("DMA-BUF image budget exceeded");
5352        }
5353        self.remove_image(handle);
5354        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5355        self.images.insert(
5356            handle,
5357            ImageTex::Nv12 {
5358                tex_y,
5359                tex_uv,
5360                binds,
5361                yuv_buf,
5362                w,
5363                h,
5364                pixel_format,
5365                fourcc: Some(canonical_fourcc(pixel_format)),
5366                color_info,
5367                external: true,
5368                last_used_frame: self.frame_index,
5369                bytes,
5370            },
5371        );
5372        self.evict_budget_excess();
5373        Ok(())
5374    }
5375
5376    pub fn image_fourcc(&self, handle: u64) -> Option<u32> {
5377        match self.images.get(&handle) {
5378            Some(ImageTex::Nv12 { fourcc, .. }) => *fourcc,
5379            _ => None,
5380        }
5381    }
5382
5383    pub fn remove_image(&mut self, handle: u64) {
5384        if let Some(img) = self.images.remove(&handle) {
5385            let b = match &img {
5386                ImageTex::Rgba { bytes, .. } => *bytes,
5387                ImageTex::Nv12 { bytes, .. } => *bytes,
5388                ImageTex::User { bytes, .. } => *bytes,
5389            };
5390            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5391        }
5392        if let Some(retained) = self.retained.remove(&handle) {
5393            self.retained_bytes_total = self
5394                .retained_bytes_total
5395                .saturating_sub(retained.rgba.len() as u64);
5396        }
5397    }
5398
5399    fn evict_image_gpu(&mut self, handle: u64) -> u64 {
5400        let Some(img) = self.images.remove(&handle) else {
5401            return 0;
5402        };
5403        let b = match &img {
5404            ImageTex::Rgba { bytes, .. } => *bytes,
5405            ImageTex::Nv12 { bytes, .. } => *bytes,
5406            ImageTex::User { bytes, .. } => *bytes,
5407        };
5408        self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5409        b
5410    }
5411
5412    fn revive_retained_image(&mut self, handle: u64) -> anyhow::Result<bool> {
5413        if self.images.contains_key(&handle) {
5414            return Ok(true);
5415        }
5416        let Some(r) = self.retained.get_mut(&handle) else {
5417            return Ok(false);
5418        };
5419        r.last_used_frame = self.frame_index;
5420        let r = r.clone();
5421        let (tex, binds) = self.create_rgba_tex(r.w, r.h, r.format);
5422
5423        self.queue.write_texture(
5424            wgpu::TexelCopyTextureInfo {
5425                texture: &tex,
5426                mip_level: 0,
5427                origin: wgpu::Origin3d::ZERO,
5428                aspect: wgpu::TextureAspect::All,
5429            },
5430            &r.rgba,
5431            wgpu::TexelCopyBufferLayout {
5432                offset: 0,
5433                bytes_per_row: Some(4 * r.w),
5434                rows_per_image: Some(r.h),
5435            },
5436            wgpu::Extent3d {
5437                width: r.w,
5438                height: r.h,
5439                depth_or_array_layers: 1,
5440            },
5441        );
5442
5443        let bytes = checked_image_bytes(r.w, r.h, 4)?;
5444        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5445        self.images.insert(
5446            handle,
5447            ImageTex::Rgba {
5448                tex,
5449                binds,
5450                w: r.w,
5451                h: r.h,
5452                format: r.format,
5453                last_used_frame: self.frame_index,
5454                bytes,
5455            },
5456        );
5457        Ok(true)
5458    }
5459
5460    fn resolve_image_for_draw(&mut self, handle: u64) -> Option<(u32, u32, bool)> {
5461        if let Some(retained) = self.retained.get_mut(&handle) {
5462            retained.last_used_frame = self.frame_index;
5463        }
5464        if let Some(t) = self.images.get_mut(&handle) {
5465            return match t {
5466                ImageTex::Rgba {
5467                    w,
5468                    h,
5469                    last_used_frame,
5470                    ..
5471                } => {
5472                    *last_used_frame = self.frame_index;
5473                    Some((*w, *h, false))
5474                }
5475                ImageTex::User {
5476                    w,
5477                    h,
5478                    last_used_frame,
5479                    ..
5480                } => {
5481                    *last_used_frame = self.frame_index;
5482                    Some((*w, *h, false))
5483                }
5484                ImageTex::Nv12 {
5485                    w,
5486                    h,
5487                    last_used_frame,
5488                    ..
5489                } => {
5490                    *last_used_frame = self.frame_index;
5491                    Some((*w, *h, true))
5492                }
5493            };
5494        }
5495        if self.revive_retained_image(handle).unwrap_or(false)
5496            && let Some(ImageTex::Rgba {
5497                w,
5498                h,
5499                last_used_frame,
5500                ..
5501            }) = self.images.get_mut(&handle)
5502        {
5503            *last_used_frame = self.frame_index;
5504            return Some((*w, *h, false));
5505        }
5506        None
5507    }
5508
5509    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
5510    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
5511        let handle = self.next_image_handle;
5512        self.next_image_handle += 1;
5513        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
5514            log::error!("Failed to register image: {e}");
5515        }
5516        handle
5517    }
5518
5519    /// Register raw RGBA8 pixels (`w * h * 4` bytes) as an image, returning
5520    /// its handle for `SceneNode::Image`. Used by CPU-rasterized overlays
5521    /// (e.g. subtitle bitmap layers) that have no encoded image bytes.
5522    /// Pass `srgb = true` for sRGB overlays composited over video.
5523    pub fn register_image_rgba8(&mut self, w: u32, h: u32, rgba: &[u8], srgb: bool) -> u64 {
5524        let handle = self.next_image_handle;
5525        self.next_image_handle += 1;
5526        if let Err(e) = self.set_image_rgba8(handle, w, h, rgba, srgb) {
5527            log::error!("Failed to register image: {e}");
5528        }
5529        handle
5530    }
5531
5532    /// Register an 8-bit coverage tile (`w * h` bytes, 0 = empty, 255 =
5533    /// fully covered) for `SceneNode::Coverage`, returning its handle.
5534    /// Coverage tiles are immutable: re-register on geometry change and
5535    /// `remove_coverage` handles you no longer emit (stale tiles also age
5536    /// out under the image eviction policy).
5537    pub fn register_coverage_a8(&mut self, w: u32, h: u32, coverage: &[u8]) -> u64 {
5538        if validate_texture_dimensions(&self.device, w, h).is_err() {
5539            log::error!("Coverage dimensions are outside the device limit");
5540            return 0;
5541        }
5542        let Ok(expected_bytes) = checked_image_bytes(w, h, 1) else {
5543            log::error!("Coverage dimensions overflow");
5544            return 0;
5545        };
5546        let Ok(expected) = usize::try_from(expected_bytes) else {
5547            log::error!("Coverage dimensions exceed addressable memory");
5548            return 0;
5549        };
5550        if coverage.len() < expected {
5551            log::error!("Coverage buffer too small: {} < {expected}", coverage.len());
5552            return 0;
5553        }
5554        let handle = self.next_coverage_handle;
5555        self.next_coverage_handle += 1;
5556
5557        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5558            label: Some("coverage tile a8"),
5559            size: wgpu::Extent3d {
5560                width: w,
5561                height: h,
5562                depth_or_array_layers: 1,
5563            },
5564            mip_level_count: 1,
5565            sample_count: 1,
5566            dimension: wgpu::TextureDimension::D2,
5567            format: wgpu::TextureFormat::R8Unorm,
5568            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5569            view_formats: &[],
5570        });
5571        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5572        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5573            label: Some("coverage bind a8"),
5574            layout: &self.image_bind_layout_rgba,
5575            entries: &[
5576                wgpu::BindGroupEntry {
5577                    binding: 0,
5578                    resource: wgpu::BindingResource::TextureView(&view),
5579                },
5580                wgpu::BindGroupEntry {
5581                    binding: 1,
5582                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5583                },
5584            ],
5585        });
5586        self.queue.write_texture(
5587            wgpu::TexelCopyTextureInfo {
5588                texture: &tex,
5589                mip_level: 0,
5590                origin: wgpu::Origin3d::ZERO,
5591                aspect: wgpu::TextureAspect::All,
5592            },
5593            &coverage[..expected],
5594            wgpu::TexelCopyBufferLayout {
5595                offset: 0,
5596                bytes_per_row: Some(w),
5597                rows_per_image: Some(h),
5598            },
5599            wgpu::Extent3d {
5600                width: w,
5601                height: h,
5602                depth_or_array_layers: 1,
5603            },
5604        );
5605        let bytes = checked_image_bytes(w, h, 1).unwrap_or(u64::MAX);
5606        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5607        self.coverages.insert(
5608            handle,
5609            CoverageTex {
5610                tex,
5611                bind,
5612                w,
5613                h,
5614                last_used_frame: self.frame_index,
5615                bytes,
5616            },
5617        );
5618        self.evict_budget_excess();
5619        handle
5620    }
5621
5622    /// Remove a coverage tile registered with [`register_coverage_a8`](Self::register_coverage_a8).
5623    pub fn remove_coverage(&mut self, handle: u64) {
5624        if let Some(tile) = self.coverages.remove(&handle) {
5625            self.image_bytes_total = self.image_bytes_total.saturating_sub(tile.bytes);
5626        }
5627    }
5628
5629    /// Tile dimensions, marking the handle used (keeps it alive under the
5630    /// eviction policy). Returns `None` for unknown handles.
5631    pub fn coverage_dimensions(&mut self, handle: u64) -> Option<(u32, u32)> {
5632        if let Some(tile) = self.coverages.get_mut(&handle) {
5633            tile.last_used_frame = self.frame_index;
5634            return Some((tile.w, tile.h));
5635        }
5636        None
5637    }
5638
5639    fn evict_retained_excess(&mut self) {
5640        let mut candidates: Vec<(u64, u64, u64)> = self
5641            .retained
5642            .iter()
5643            .map(|(handle, image)| (*handle, image.last_used_frame, image.rgba.len() as u64))
5644            .collect();
5645        candidates.sort_by_key(|candidate| candidate.1);
5646        for (handle, _last_used, bytes) in candidates {
5647            if self.retained.len() <= MAX_RETAINED_IMAGES
5648                && self.retained_bytes_total <= MAX_RETAINED_IMAGE_BYTES
5649            {
5650                break;
5651            }
5652            if let Some(image) = self.retained.remove(&handle) {
5653                self.retained_bytes_total = self
5654                    .retained_bytes_total
5655                    .saturating_sub(bytes.min(image.rgba.len() as u64));
5656            }
5657        }
5658    }
5659
5660    fn evict_unused_images(&mut self) {
5661        let now = self.frame_index;
5662        let evict_after = self.image_evict_after_frames;
5663
5664        // Time based eviction. Eviction only frees GPU memory: retained RGBA
5665        // sources stay so the image can be lazily re-uploaded when drawn again.
5666        let mut to_evict = Vec::new();
5667        for (h, t) in self.images.iter() {
5668            if !t.evictable() {
5669                continue;
5670            }
5671            let last = match t {
5672                ImageTex::Rgba {
5673                    last_used_frame, ..
5674                } => *last_used_frame,
5675                ImageTex::User {
5676                    last_used_frame, ..
5677                } => *last_used_frame,
5678                ImageTex::Nv12 {
5679                    last_used_frame, ..
5680                } => *last_used_frame,
5681            };
5682            if now.saturating_sub(last) > evict_after {
5683                to_evict.push(*h);
5684            }
5685        }
5686        for h in to_evict {
5687            if self.retained.contains_key(&h) {
5688                self.evict_image_gpu(h);
5689            } else {
5690                self.remove_image(h);
5691            }
5692        }
5693
5694        // Coverage tiles have no retained CPU copies: age-out removes them.
5695        let mut stale = Vec::new();
5696        for (h, t) in self.coverages.iter() {
5697            if now.saturating_sub(t.last_used_frame) > evict_after {
5698                stale.push(*h);
5699            }
5700        }
5701        for h in stale {
5702            self.remove_coverage(h);
5703        }
5704
5705        self.evict_budget_excess();
5706    }
5707
5708    fn next_cache_touch(&mut self) -> u64 {
5709        let touch = self.cache_touch_clock;
5710        self.cache_touch_clock = self.cache_touch_clock.wrapping_add(1);
5711        touch
5712    }
5713
5714    fn gpu_bytes_total(&self) -> u64 {
5715        self.image_bytes_total
5716            .saturating_add(self.layer_bytes_total)
5717            .saturating_add(self.blend_snapshot_bytes_total)
5718            .saturating_add(self.mesh_cache_bytes_total)
5719            .saturating_add(self.working_space_bytes)
5720            .saturating_add(self.surface_resolve_bytes)
5721            .saturating_add(self.msaa_bytes)
5722            .saturating_add(self.ws_msaa_bytes)
5723            .saturating_add(self.depth_stencil_bytes)
5724    }
5725
5726    fn budget_exceeded(&self) -> bool {
5727        self.image_bytes_total > self.image_budget_bytes
5728            || self.gpu_bytes_total() > self.gpu_budget_bytes
5729    }
5730
5731    fn evict_budget_excess(&mut self) {
5732        let image_over_budget = self.image_bytes_total > self.image_budget_bytes;
5733        let gpu_over_budget = self.gpu_bytes_total() > self.gpu_budget_bytes;
5734        if !image_over_budget && !gpu_over_budget {
5735            return;
5736        }
5737        let mut coverage_candidates: Vec<(u64, u64)> = self
5738            .coverages
5739            .iter()
5740            .map(|(handle, tile)| (*handle, tile.last_used_frame))
5741            .collect();
5742        coverage_candidates.sort_by_key(|candidate| candidate.1);
5743        let now = self.frame_index;
5744        for (handle, last_used) in coverage_candidates {
5745            if !self.budget_exceeded() {
5746                break;
5747            }
5748            if last_used == now {
5749                continue;
5750            }
5751            self.remove_coverage(handle);
5752        }
5753        if !self.budget_exceeded() {
5754            return;
5755        }
5756        // Collect (handle, last_used, bytes)
5757        let mut candidates: Vec<(u64, u64, u64)> = self
5758            .images
5759            .iter()
5760            .filter_map(|(h, t)| {
5761                if !t.evictable() {
5762                    return None;
5763                }
5764                let (last, bytes) = match t {
5765                    ImageTex::Rgba {
5766                        last_used_frame,
5767                        bytes,
5768                        ..
5769                    } => (*last_used_frame, *bytes),
5770                    ImageTex::User {
5771                        last_used_frame,
5772                        bytes,
5773                        ..
5774                    } => (*last_used_frame, *bytes),
5775                    ImageTex::Nv12 {
5776                        last_used_frame,
5777                        bytes,
5778                        ..
5779                    } => (*last_used_frame, *bytes),
5780                };
5781                Some((*h, last, bytes))
5782            })
5783            .collect();
5784
5785        // Sort by last_used ascending (LRU first)
5786        candidates.sort_by_key(|k| k.1);
5787
5788        for (h, last, _bytes) in candidates {
5789            if !self.budget_exceeded() {
5790                break;
5791            }
5792            if last == now {
5793                continue;
5794            }
5795            if self.retained.contains_key(&h) {
5796                self.evict_image_gpu(h);
5797            } else {
5798                self.remove_image(h);
5799            }
5800        }
5801        while self.gpu_bytes_total() > self.gpu_budget_bytes {
5802            if !self.evict_mesh_cache() {
5803                break;
5804            }
5805        }
5806        if self.gpu_bytes_total() > self.gpu_budget_bytes {
5807            log::warn!(
5808                "renderer GPU resource budget exceeded: {} > {}",
5809                self.gpu_bytes_total(),
5810                self.gpu_budget_bytes
5811            );
5812        }
5813    }
5814
5815    /// Set pixels per point (DPI scale) for callback `ScreenDescriptor` / `PaintCallbackInfo`.
5816    pub fn set_pixels_per_point(&mut self, ppp: f32) {
5817        if ppp.is_finite() {
5818            self.pixels_per_point = ppp.clamp(0.5, 8.0);
5819        }
5820    }
5821
5822    /// Enable or disable linear working-space rendering.
5823    /// When enabled, the scene is rendered into an Rgba16Float intermediate
5824    /// and a final full-screen pass applies the display OETF.
5825    pub fn set_working_space(&mut self, enabled: bool) {
5826        if enabled == self.working_space {
5827            return;
5828        }
5829        self.working_space = enabled;
5830        if enabled {
5831            self.ensure_display_pipeline();
5832            self.recreate_msaa_and_depth_stencil();
5833            self.recreate_working_space_texture();
5834        } else {
5835            self.ws_tex = None;
5836            self.ws_view = None;
5837            self.ws_bind = None;
5838            self.ws_msaa_tex = None;
5839            self.ws_msaa_view = None;
5840            self.surface_resolve_tex = None;
5841            self.surface_resolve_view = None;
5842            self.surface_resolve_bytes = 0;
5843            self.working_space_bytes = 0;
5844            self.recreate_msaa_and_depth_stencil();
5845        }
5846        self.evict_budget_excess();
5847    }
5848
5849    fn ensure_display_pipeline(&mut self) {
5850        if self.display_pipeline.is_some() {
5851            return;
5852        }
5853
5854        let layout = self
5855            .device
5856            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5857                label: Some("display transform layout"),
5858                entries: &[
5859                    wgpu::BindGroupLayoutEntry {
5860                        binding: 0,
5861                        visibility: wgpu::ShaderStages::FRAGMENT,
5862                        ty: wgpu::BindingType::Texture {
5863                            multisampled: false,
5864                            view_dimension: wgpu::TextureViewDimension::D2,
5865                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5866                        },
5867                        count: None,
5868                    },
5869                    wgpu::BindGroupLayoutEntry {
5870                        binding: 1,
5871                        visibility: wgpu::ShaderStages::FRAGMENT,
5872                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5873                        count: None,
5874                    },
5875                ],
5876            });
5877        self.display_layout = Some(layout);
5878
5879        let display_source: Cow<'static, str> = if self.output_format.is_srgb() {
5880            Cow::Borrowed(include_str!("shaders/display_transform_passthrough.wgsl"))
5881        } else {
5882            Cow::Borrowed(include_str!("shaders/display_transform.wgsl"))
5883        };
5884        let shader = self
5885            .device
5886            .create_shader_module(wgpu::ShaderModuleDescriptor {
5887                label: Some("display transform"),
5888                source: wgpu::ShaderSource::Wgsl(display_source),
5889            });
5890
5891        let pipeline_layout = self
5892            .device
5893            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
5894                label: Some("display transform pipeline layout"),
5895                bind_group_layouts: &[None, self.display_layout.as_ref()],
5896                immediate_size: 0,
5897            });
5898
5899        let pipeline = self
5900            .device
5901            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
5902                label: Some("display transform pipeline"),
5903                layout: Some(&pipeline_layout),
5904                vertex: wgpu::VertexState {
5905                    module: &shader,
5906                    entry_point: Some("vs_main"),
5907                    buffers: &[],
5908                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5909                },
5910                fragment: Some(wgpu::FragmentState {
5911                    module: &shader,
5912                    entry_point: Some("fs_main"),
5913                    targets: &[Some(wgpu::ColorTargetState {
5914                        format: self.output_format,
5915                        blend: None,
5916                        write_mask: wgpu::ColorWrites::ALL,
5917                    })],
5918                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5919                }),
5920                primitive: wgpu::PrimitiveState::default(),
5921                depth_stencil: None,
5922                multisample: wgpu::MultisampleState::default(),
5923                multiview_mask: None,
5924                cache: None,
5925            });
5926        self.display_pipeline = Some(pipeline);
5927    }
5928
5929    /// Resize the render target dimensions.
5930    ///
5931    /// Recreates MSAA, depth-stencil, and working-space textures to match the
5932    /// new size..
5933    pub fn resize(&mut self, width: u32, height: u32) {
5934        let (width, height) =
5935            clamp_output_size(width, height, self.device.limits().max_texture_dimension_2d);
5936        if self.output_width == width && self.output_height == height {
5937            return;
5938        }
5939        self.output_width = width;
5940        self.output_height = height;
5941        if width == 0 || height == 0 {
5942            self.msaa_tex = None;
5943            self.msaa_view = None;
5944            self.ws_msaa_tex = None;
5945            self.ws_msaa_view = None;
5946            self.surface_resolve_tex = None;
5947            self.surface_resolve_view = None;
5948            self.surface_resolve_bytes = 0;
5949            self.msaa_bytes = 0;
5950            self.ws_msaa_bytes = 0;
5951            self.depth_stencil_bytes = 0;
5952            self.working_space_bytes = 0;
5953            self.ws_tex = None;
5954            self.ws_view = None;
5955            self.ws_bind = None;
5956            self.evict_budget_excess();
5957            return;
5958        }
5959        self.recreate_msaa_and_depth_stencil();
5960        self.recreate_working_space_texture();
5961        self.evict_budget_excess();
5962    }
5963
5964    fn recreate_working_space_texture(&mut self) {
5965        if !self.working_space || self.output_width == 0 || self.output_height == 0 {
5966            return;
5967        }
5968        self.working_space_bytes = 0;
5969        let w = self
5970            .output_width
5971            .clamp(1, self.device.limits().max_texture_dimension_2d);
5972        let h = self
5973            .output_height
5974            .clamp(1, self.device.limits().max_texture_dimension_2d);
5975
5976        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5977            label: Some("working space"),
5978            size: wgpu::Extent3d {
5979                width: w,
5980                height: h,
5981                depth_or_array_layers: 1,
5982            },
5983            mip_level_count: 1,
5984            sample_count: 1,
5985            dimension: wgpu::TextureDimension::D2,
5986            format: wgpu::TextureFormat::Rgba16Float,
5987            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5988                | wgpu::TextureUsages::TEXTURE_BINDING
5989                | wgpu::TextureUsages::COPY_SRC
5990                | wgpu::TextureUsages::COPY_DST,
5991            view_formats: &[],
5992        });
5993        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5994
5995        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5996            label: Some("working space bind"),
5997            layout: self.display_layout.as_ref().unwrap(),
5998            entries: &[
5999                wgpu::BindGroupEntry {
6000                    binding: 0,
6001                    resource: wgpu::BindingResource::TextureView(&view),
6002                },
6003                wgpu::BindGroupEntry {
6004                    binding: 1,
6005                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
6006                },
6007            ],
6008        });
6009
6010        self.working_space_bytes =
6011            texture_storage_bytes(wgpu::TextureFormat::Rgba16Float, w, h).unwrap_or(0);
6012        self.ws_tex = Some(tex);
6013        self.ws_view = Some(view);
6014        self.ws_bind = Some(bind);
6015    }
6016
6017    fn recreate_msaa_and_depth_stencil(&mut self) {
6018        if self.output_width == 0 || self.output_height == 0 {
6019            self.msaa_tex = None;
6020            self.msaa_view = None;
6021            self.ws_msaa_tex = None;
6022            self.ws_msaa_view = None;
6023            self.surface_resolve_tex = None;
6024            self.surface_resolve_view = None;
6025            self.surface_resolve_bytes = 0;
6026            self.msaa_bytes = 0;
6027            self.ws_msaa_bytes = 0;
6028            self.depth_stencil_bytes = 0;
6029            return;
6030        }
6031        self.msaa_bytes = 0;
6032        self.ws_msaa_bytes = 0;
6033        self.depth_stencil_bytes = 0;
6034        let aliased_surface = self.surface_format != self.output_format;
6035        let view_format_list = [self.output_format];
6036        let surface_view_formats: &[wgpu::TextureFormat] = if aliased_surface {
6037            &view_format_list
6038        } else {
6039            &[]
6040        };
6041        let surface_view = if aliased_surface {
6042            wgpu::TextureViewDescriptor {
6043                format: Some(self.output_format),
6044                ..Default::default()
6045            }
6046        } else {
6047            wgpu::TextureViewDescriptor::default()
6048        };
6049        if self.msaa_samples > 1 && !self.working_space {
6050            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6051                label: Some("msaa color"),
6052                size: wgpu::Extent3d {
6053                    width: self.output_width.max(1),
6054                    height: self.output_height.max(1),
6055                    depth_or_array_layers: 1,
6056                },
6057                mip_level_count: 1,
6058                sample_count: self.msaa_samples,
6059                dimension: wgpu::TextureDimension::D2,
6060                format: self.surface_format,
6061                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6062                view_formats: surface_view_formats,
6063            });
6064            let view = tex.create_view(&surface_view);
6065            self.msaa_bytes = texture_storage_bytes_with_samples(
6066                self.surface_format,
6067                self.output_width.max(1),
6068                self.output_height.max(1),
6069                self.msaa_samples,
6070            )
6071            .unwrap_or(0);
6072            self.msaa_tex = Some(tex);
6073            self.msaa_view = Some(view);
6074        } else {
6075            self.msaa_tex = None;
6076            self.msaa_view = None;
6077        }
6078        if self.working_space && self.working_space_msaa_samples > 1 {
6079            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6080                label: Some("working space msaa color"),
6081                size: wgpu::Extent3d {
6082                    width: self.output_width.max(1),
6083                    height: self.output_height.max(1),
6084                    depth_or_array_layers: 1,
6085                },
6086                mip_level_count: 1,
6087                sample_count: self.working_space_msaa_samples,
6088                dimension: wgpu::TextureDimension::D2,
6089                format: wgpu::TextureFormat::Rgba16Float,
6090                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6091                view_formats: &[],
6092            });
6093            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6094            self.ws_msaa_bytes = texture_storage_bytes_with_samples(
6095                wgpu::TextureFormat::Rgba16Float,
6096                self.output_width.max(1),
6097                self.output_height.max(1),
6098                self.working_space_msaa_samples,
6099            )
6100            .unwrap_or(0);
6101            self.ws_msaa_tex = Some(tex);
6102            self.ws_msaa_view = Some(view);
6103        } else {
6104            self.ws_msaa_tex = None;
6105            self.ws_msaa_view = None;
6106        }
6107        self.surface_resolve_tex = None;
6108        self.surface_resolve_view = None;
6109        self.surface_resolve_bytes = 0;
6110        if self.msaa_samples > 1 && !self.working_space {
6111            let width = self.output_width.max(1);
6112            let height = self.output_height.max(1);
6113            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6114                label: Some("surface resolve"),
6115                size: wgpu::Extent3d {
6116                    width,
6117                    height,
6118                    depth_or_array_layers: 1,
6119                },
6120                mip_level_count: 1,
6121                sample_count: 1,
6122                dimension: wgpu::TextureDimension::D2,
6123                format: self.surface_format,
6124                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6125                    | wgpu::TextureUsages::TEXTURE_BINDING
6126                    | wgpu::TextureUsages::COPY_SRC,
6127                view_formats: surface_view_formats,
6128            });
6129            let view = tex.create_view(&surface_view);
6130            self.surface_resolve_bytes =
6131                texture_storage_bytes(self.surface_format, width, height).unwrap_or(0);
6132            self.surface_resolve_tex = Some(tex);
6133            self.surface_resolve_view = Some(view);
6134        }
6135
6136        self.depth_stencil_bytes = texture_storage_bytes_with_samples(
6137            wgpu::TextureFormat::Depth24PlusStencil8,
6138            self.output_width.max(1),
6139            self.output_height.max(1),
6140            self.active_surface_msaa_samples(),
6141        )
6142        .unwrap_or(0);
6143        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
6144            label: Some("depth-stencil (stencil clips)"),
6145            size: wgpu::Extent3d {
6146                width: self.output_width.max(1),
6147                height: self.output_height.max(1),
6148                depth_or_array_layers: 1,
6149            },
6150            mip_level_count: 1,
6151            sample_count: self.active_surface_msaa_samples(),
6152            dimension: wgpu::TextureDimension::D2,
6153            format: wgpu::TextureFormat::Depth24PlusStencil8,
6154            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6155            view_formats: &[],
6156        });
6157        self.depth_stencil_view = self
6158            .depth_stencil_tex
6159            .create_view(&wgpu::TextureViewDescriptor::default());
6160    }
6161
6162    fn active_surface_msaa_samples(&self) -> u32 {
6163        if self.working_space {
6164            self.working_space_msaa_samples
6165        } else {
6166            self.msaa_samples
6167        }
6168    }
6169
6170    fn layer_target_format(&self) -> wgpu::TextureFormat {
6171        if self.working_space {
6172            wgpu::TextureFormat::Rgba16Float
6173        } else {
6174            self.output_format
6175        }
6176    }
6177
6178    fn get_or_create_layer(
6179        &mut self,
6180        layer_id: u32,
6181        width: u32,
6182        height: u32,
6183        rect: repose_core::Rect,
6184        transient: bool,
6185    ) -> bool {
6186        if width == 0
6187            || height == 0
6188            || width > self.device.limits().max_texture_dimension_2d
6189            || height > self.device.limits().max_texture_dimension_2d
6190        {
6191            return false;
6192        }
6193        let needs_alloc = match self.layer_pool.get(&layer_id) {
6194            Some(lt) => {
6195                lt.width != width || lt.height != height || lt.format != self.layer_target_format()
6196            }
6197            None => true,
6198        };
6199        if !needs_alloc {
6200            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
6201                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
6202            }
6203            return true;
6204        }
6205        let color_bytes =
6206            texture_storage_bytes(self.layer_target_format(), width.max(1), height.max(1))
6207                .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6208        let depth_bytes = checked_image_bytes(width.max(1), height.max(1), 4)
6209            .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6210        let bytes = color_bytes.saturating_add(depth_bytes);
6211        let old = self.layer_pool.get(&layer_id);
6212        let old_bytes = old.map_or(0, |layer| layer.bytes);
6213        let old_transient_bytes =
6214            old.map_or(0, |layer| if layer.transient { layer.bytes } else { 0 });
6215        let transient_count = self
6216            .layer_pool
6217            .values()
6218            .filter(|layer| layer.transient)
6219            .count();
6220        let transient_count = transient_count
6221            .saturating_sub(usize::from(old.is_some_and(|layer| layer.transient)))
6222            .saturating_add(usize::from(transient));
6223        let count = self.layer_pool.len() + usize::from(!self.layer_pool.contains_key(&layer_id));
6224        let total = self
6225            .layer_bytes_total
6226            .saturating_sub(old_bytes)
6227            .saturating_add(bytes);
6228        let projected_gpu = self
6229            .gpu_bytes_total()
6230            .saturating_sub(old_bytes)
6231            .saturating_add(bytes);
6232        let projected_transient = self
6233            .transient_layer_bytes_total
6234            .saturating_sub(old_transient_bytes)
6235            .saturating_add(if transient { bytes } else { 0 });
6236        if count > MAX_GRAPHICS_LAYERS
6237            || (transient
6238                && (transient_count > MAX_TRANSIENT_GRAPHICS_LAYERS
6239                    || projected_transient > MAX_TRANSIENT_GRAPHICS_LAYER_BYTES))
6240            || bytes > MAX_GRAPHICS_LAYER_BYTES
6241            || total > MAX_GRAPHICS_LAYER_BYTES
6242            || projected_gpu > self.gpu_budget_bytes
6243        {
6244            log::warn!("graphics layer budget exhausted; layer {layer_id} skipped");
6245            if let Some(old) = self.layer_pool.remove(&layer_id) {
6246                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6247                if old.transient {
6248                    self.transient_layer_bytes_total =
6249                        self.transient_layer_bytes_total.saturating_sub(old.bytes);
6250                }
6251            }
6252            return false;
6253        }
6254        if let Some(old) = self.layer_pool.remove(&layer_id) {
6255            self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6256            if old.transient {
6257                self.transient_layer_bytes_total =
6258                    self.transient_layer_bytes_total.saturating_sub(old.bytes);
6259            }
6260        }
6261        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6262            label: Some("graphics layer"),
6263            size: wgpu::Extent3d {
6264                width: width.max(1),
6265                height: height.max(1),
6266                depth_or_array_layers: 1,
6267            },
6268            mip_level_count: 1,
6269            sample_count: 1,
6270            dimension: wgpu::TextureDimension::D2,
6271            format: self.layer_target_format(),
6272            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6273                | wgpu::TextureUsages::TEXTURE_BINDING
6274                | wgpu::TextureUsages::COPY_SRC,
6275            view_formats: &[],
6276        });
6277        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6278        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6279            label: Some("layer bind"),
6280            layout: &self.image_bind_layout_rgba,
6281            entries: &[
6282                wgpu::BindGroupEntry {
6283                    binding: 0,
6284                    resource: wgpu::BindingResource::TextureView(&view),
6285                },
6286                wgpu::BindGroupEntry {
6287                    binding: 1,
6288                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
6289                },
6290            ],
6291        });
6292        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6293            label: Some("layer bind linear"),
6294            layout: &self.image_bind_layout_rgba,
6295            entries: &[
6296                wgpu::BindGroupEntry {
6297                    binding: 0,
6298                    resource: wgpu::BindingResource::TextureView(&view),
6299                },
6300                wgpu::BindGroupEntry {
6301                    binding: 1,
6302                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
6303                },
6304            ],
6305        });
6306        let depth_stencil_view = self.create_layer_depth_stencil(width, height);
6307        self.layer_pool.insert(
6308            layer_id,
6309            LayerTarget {
6310                texture: tex,
6311                view,
6312                bind,
6313                bind_linear,
6314                depth_stencil_view,
6315                width,
6316                height,
6317                format: self.layer_target_format(),
6318                bytes,
6319                rect_px: (rect.x, rect.y, rect.w, rect.h),
6320                transient,
6321            },
6322        );
6323        self.layer_bytes_total = self.layer_bytes_total.saturating_add(bytes);
6324        if transient {
6325            self.transient_layer_bytes_total =
6326                self.transient_layer_bytes_total.saturating_add(bytes);
6327        }
6328        true
6329    }
6330
6331    /// Allocate (or reuse) the padded scratch layer holding the horizontal half
6332    /// of a separable blur for `layer_id` in `slot`.
6333    ///
6334    /// Scratch ids are handed out from [`BLUR_SCRATCH_ID_BASE`] and remembered
6335    /// per `(layer_id, slot)` in [`Self::blur_scratch_ids`], so a stable layer
6336    /// reuses its allocation across frames while a content blur and a shadow
6337    /// on the same layer never share one. Allocation goes through the layer
6338    /// pool (as a transient layer) and therefore respects the layer budget;
6339    /// `None` means the caller should composite unblurred.
6340    fn acquire_blur_scratch(&mut self, layer_id: u32, slot: u32, pad: (f32, f32)) -> Option<u32> {
6341        let src = self.layer_pool.get(&layer_id)?;
6342        let width_f = (src.width as f32 + pad.0 * 2.0).ceil();
6343        let height_f = (src.height as f32 + pad.1 * 2.0).ceil();
6344        let max_dim = self.device.limits().max_texture_dimension_2d as f32;
6345        if !width_f.is_finite()
6346            || !height_f.is_finite()
6347            || width_f < 1.0
6348            || height_f < 1.0
6349            || width_f > max_dim
6350            || height_f > max_dim
6351        {
6352            log::warn!("blur scratch size {width_f}x{height_f} is invalid");
6353            return None;
6354        }
6355        let next = self.blur_scratch_next;
6356        let scratch_id = match self.blur_scratch_ids.get(&(layer_id, slot)) {
6357            Some(&id) => id,
6358            None => {
6359                let id = BLUR_SCRATCH_ID_BASE.wrapping_add(next);
6360                self.blur_scratch_next = next.wrapping_add(1);
6361                self.blur_scratch_ids.insert((layer_id, slot), id);
6362                id
6363            }
6364        };
6365        let allocated = self.get_or_create_layer(
6366            scratch_id,
6367            width_f as u32,
6368            height_f as u32,
6369            repose_core::Rect {
6370                x: 0.0,
6371                y: 0.0,
6372                w: width_f,
6373                h: height_f,
6374            },
6375            true,
6376        );
6377        allocated.then_some(scratch_id)
6378    }
6379
6380    /// Emit the horizontal half of a separable blur for `source_layer` into a
6381    /// padded scratch layer, and return that scratch's id so the caller can
6382    /// finish the vertical axis when it composites.
6383    ///
6384    /// The scratch pass is spliced between the pass being built and the pass the
6385    /// caller continues in, so ordering stays: everything drawn so far, the
6386    /// horizontal blur, then the vertical composite. `None` means no scratch
6387    /// could be allocated and the caller should composite without the blur.
6388    ///
6389    /// `slot` picks the scratch this blur owns, so concurrent blurs of one
6390    /// layer (content and shadow) get their own texture. The horizontal
6391    /// instance carries no tint: the scratch holds the raw blurred signal and
6392    /// the caller applies the group alpha / shadow colour on the vertical pass,
6393    /// which would otherwise square the alpha across the two axes.
6394    ///
6395    /// `alpha_only` selects the shadow kernel (alpha, premultiplied by the
6396    /// tint the caller applies vertically) over the full-RGBA kernel used by
6397    /// `Modifier::blur`.
6398    #[allow(clippy::too_many_arguments)]
6399    fn emit_separable_blur(
6400        &mut self,
6401        source_layer: u32,
6402        slot: u32,
6403        sigma: [f32; 2],
6404        pad: (f32, f32),
6405        edge_mode: u32,
6406        alpha_only: bool,
6407        shape: [f32; 4],
6408        passes: &mut Vec<Pass>,
6409        current_pass: &mut Pass,
6410        next_pass_id: &mut u64,
6411        encoder: &mut wgpu::CommandEncoder,
6412    ) -> Option<u32> {
6413        let scratch_id = self.acquire_blur_scratch(source_layer, slot, pad)?;
6414        let (scratch_w, scratch_h, source_w, source_h) = {
6415            let scratch = self.layer_pool.get(&scratch_id)?;
6416            let source = self.layer_pool.get(&source_layer)?;
6417            (
6418                scratch.width,
6419                scratch.height,
6420                source.width as f32,
6421                source.height as f32,
6422            )
6423        };
6424        // The quad covers the whole scratch, and scratch texel (x, y) samples
6425        // the source at (x - pad_x, y - pad_y). Padding texels therefore land
6426        // outside the source's 0..1, and `edge_mode` decides whether that
6427        // contributes nothing (decal, correct for a shadow) or clamps to the
6428        // edge texel (the `Modifier::blur` clamp edge treatment).
6429        let uv = [
6430            -pad.0 / source_w,
6431            -pad.1 / source_h,
6432            1.0 + pad.0 / source_w,
6433            1.0 + pad.1 / source_h,
6434        ];
6435        let ndc = [-1.0, -1.0, 2.0, 2.0];
6436        let inst = BlurInstance {
6437            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
6438            uv,
6439            color: [1.0, 1.0, 1.0, 1.0],
6440            sigma_px: sigma,
6441            fwd_mat: [1.0, 0.0, 0.0, 1.0],
6442            axis: 0,
6443            edge_mode,
6444            shape,
6445        };
6446        let cmd = match self.upload_blur_instance(&inst, encoder) {
6447            Some(off) if alpha_only => Cmd::CompositeShadow {
6448                off,
6449                cnt: 1,
6450                layer_id: source_layer,
6451            },
6452            Some(off) => Cmd::CompositeBlur {
6453                off,
6454                cnt: 1,
6455                layer_id: source_layer,
6456            },
6457            None => return None,
6458        };
6459        let target = current_pass.target;
6460        let scissor = current_pass.active_scissor;
6461        let resumed_pass_id = *next_pass_id;
6462        *next_pass_id += 1;
6463        let saved = std::mem::replace(
6464            current_pass,
6465            Pass {
6466                id: resumed_pass_id,
6467                target,
6468                initial_scissor: scissor.unwrap_or((0, 0, 1, 1)),
6469                active_scissor: scissor,
6470                clear_color: None,
6471                cmds: Vec::new(),
6472            },
6473        );
6474        passes.push(saved);
6475        let scratch_pass_id = *next_pass_id;
6476        *next_pass_id += 1;
6477        passes.push(Pass {
6478            id: scratch_pass_id,
6479            target: PassTarget::Layer(scratch_id),
6480            initial_scissor: (0, 0, scratch_w, scratch_h),
6481            active_scissor: Some((0, 0, scratch_w, scratch_h)),
6482            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
6483            cmds: vec![cmd],
6484        });
6485        Some(scratch_id)
6486    }
6487
6488    fn upload_blur_instance(
6489        &mut self,
6490        inst: &BlurInstance,
6491        encoder: &mut wgpu::CommandEncoder,
6492    ) -> Option<u64> {
6493        self.blur_ring
6494            .grow_to_fit(
6495                &self.device,
6496                encoder,
6497                std::mem::size_of::<BlurInstance>() as u64,
6498            )
6499            .ok()?;
6500        self.blur_ring
6501            .alloc_write(&self.queue, bytemuck::bytes_of(inst))
6502            .ok()
6503    }
6504    fn create_layer_depth_stencil(&self, width: u32, height: u32) -> wgpu::TextureView {
6505        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6506            label: Some("layer depth-stencil"),
6507            size: wgpu::Extent3d {
6508                width: width.max(1),
6509                height: height.max(1),
6510                depth_or_array_layers: 1,
6511            },
6512            mip_level_count: 1,
6513            sample_count: 1,
6514            dimension: wgpu::TextureDimension::D2,
6515            format: wgpu::TextureFormat::Depth24PlusStencil8,
6516            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6517            view_formats: &[],
6518        });
6519        tex.create_view(&wgpu::TextureViewDescriptor::default())
6520    }
6521
6522    fn ensure_globals_staging(&mut self, pass_count: usize) -> bool {
6523        let size = std::mem::size_of::<Globals>() as u64;
6524        let needed = match (pass_count.max(1) as u64).checked_mul(size) {
6525            Some(needed) => needed,
6526            None => return false,
6527        };
6528        if needed <= self.globals_staging_cap {
6529            return true;
6530        }
6531        let cap = needed
6532            .next_power_of_two()
6533            .min(self.device.limits().max_buffer_size);
6534        if cap < needed {
6535            return false;
6536        }
6537        self.globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
6538            label: Some("globals staging"),
6539            size: cap,
6540            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
6541            mapped_at_creation: false,
6542        });
6543        self.globals_staging_cap = cap;
6544        true
6545    }
6546
6547    fn reset_atlas_storage(&mut self) {
6548        self.atlas_mask = init_atlas_mask(&self.device);
6549        self.atlas_color = init_atlas_color(&self.device);
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        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6565            label: Some("atlas color bind"),
6566            layout: &self.text_bind_layout,
6567            entries: &[
6568                wgpu::BindGroupEntry {
6569                    binding: 0,
6570                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6571                },
6572                wgpu::BindGroupEntry {
6573                    binding: 1,
6574                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6575                },
6576            ],
6577        });
6578        self.atlas_mask_pending = AtlasPending::default();
6579        self.atlas_color_pending = AtlasPending::default();
6580        self.atlas_mask_deferred.clear();
6581        self.atlas_color_deferred.clear();
6582    }
6583
6584    /// Apply atlas growth and compaction scheduled during the previous frame.
6585    /// Called from `begin_frame`, before any glyph is batched: both operations
6586    /// re-pack the atlas, so running them mid-frame would leave already-batched
6587    /// instances pointing at the old packing.
6588    fn apply_atlas_pending(&mut self) {
6589        if self.atlas_mask_pending.grow {
6590            self.atlas_mask_pending.grow = false;
6591            let size = (self.atlas_mask.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6592            self.repack_mask(size);
6593        }
6594        if self.atlas_color_pending.grow {
6595            self.atlas_color_pending.grow = false;
6596            let size = (self.atlas_color.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6597            self.repack_color(size);
6598        }
6599        if self.frame_index.wrapping_sub(self.atlas_last_compact_frame)
6600            < ATLAS_COMPACT_MIN_FRAME_INTERVAL
6601        {
6602            return;
6603        }
6604        // Stay armed when a compaction finds nothing evictable, so the deferred
6605        // glyphs it would rescue get another chance next frame.
6606        if self.atlas_mask_pending.compact && self.compact_atlas_mask() {
6607            self.atlas_mask_pending.compact = false;
6608        }
6609        if self.atlas_color_pending.compact && self.compact_atlas_color() {
6610            self.atlas_color_pending.compact = false;
6611        }
6612    }
6613
6614    /// Drop the least recently used mask glyphs and re-pack at the same size.
6615    /// A shelf packer cannot hand back an individual cell, so reclaiming space
6616    /// means evicting cold entries and rebuilding around the warm ones. Returns
6617    /// false when there was nothing to evict.
6618    fn compact_atlas_mask(&mut self) -> bool {
6619        let target = self.atlas_mask.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6620        if !evict_atlas_lru(
6621            &mut self.atlas_mask.map,
6622            &mut self.atlas_mask.used_area,
6623            self.frame_index,
6624            target,
6625        ) {
6626            return false;
6627        }
6628        let size = self.atlas_mask.size;
6629        self.repack_mask(size);
6630        self.atlas_last_compact_frame = self.frame_index;
6631        true
6632    }
6633
6634    fn compact_atlas_color(&mut self) -> bool {
6635        let target = self.atlas_color.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6636        if !evict_atlas_lru(
6637            &mut self.atlas_color.map,
6638            &mut self.atlas_color.used_area,
6639            self.frame_index,
6640            target,
6641        ) {
6642            return false;
6643        }
6644        let size = self.atlas_color.size;
6645        self.repack_color(size);
6646        self.atlas_last_compact_frame = self.frame_index;
6647        true
6648    }
6649
6650    /// Re-pack the mask atlas into a `new_size` texture and re-upload every
6651    /// surviving entry, preserving its recency so a repack does not make warm
6652    /// glyphs look cold.
6653    fn repack_mask(&mut self, new_size: u32) {
6654        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6655            label: Some("glyph atlas A8 (repacked)"),
6656            size: wgpu::Extent3d {
6657                width: new_size,
6658                height: new_size,
6659                depth_or_array_layers: 1,
6660            },
6661            mip_level_count: 1,
6662            sample_count: 1,
6663            dimension: wgpu::TextureDimension::D2,
6664            format: wgpu::TextureFormat::R8Unorm,
6665            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6666            view_formats: &[],
6667        });
6668        self.atlas_mask.tex = tex;
6669        self.atlas_mask.view = self
6670            .atlas_mask
6671            .tex
6672            .create_view(&wgpu::TextureViewDescriptor::default());
6673        self.atlas_mask.size = new_size;
6674        self.atlas_mask.next_x = 1;
6675        self.atlas_mask.next_y = 1;
6676        self.atlas_mask.row_h = 0;
6677        self.atlas_mask.used_area = 0;
6678        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6679            label: Some("atlas mask bind"),
6680            layout: &self.text_bind_layout,
6681            entries: &[
6682                wgpu::BindGroupEntry {
6683                    binding: 0,
6684                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6685                },
6686                wgpu::BindGroupEntry {
6687                    binding: 1,
6688                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6689                },
6690            ],
6691        });
6692        let survivors: Vec<_> = self.atlas_mask.map.drain().collect();
6693        self.atlas_mask_deferred.clear();
6694        for (key, previous) in survivors {
6695            let _ = self.upload_glyph_mask(key.clone(), f32::from_bits(key.font_size_bits));
6696            if let Some(info) = self.atlas_mask.map.get_mut(&key) {
6697                info.last_touch = previous.last_touch;
6698                info.last_used_frame = previous.last_used_frame;
6699            }
6700        }
6701    }
6702
6703    /// Re-pack the color atlas into a `new_size` texture. See [`Self::repack_mask`].
6704    fn repack_color(&mut self, new_size: u32) {
6705        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6706            label: Some("glyph atlas RGBA (repacked)"),
6707            size: wgpu::Extent3d {
6708                width: new_size,
6709                height: new_size,
6710                depth_or_array_layers: 1,
6711            },
6712            mip_level_count: 1,
6713            sample_count: 1,
6714            dimension: wgpu::TextureDimension::D2,
6715            format: wgpu::TextureFormat::Rgba8UnormSrgb,
6716            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6717            view_formats: &[],
6718        });
6719        self.atlas_color.tex = tex;
6720        self.atlas_color.view = self
6721            .atlas_color
6722            .tex
6723            .create_view(&wgpu::TextureViewDescriptor::default());
6724        self.atlas_color.size = new_size;
6725        self.atlas_color.next_x = 1;
6726        self.atlas_color.next_y = 1;
6727        self.atlas_color.row_h = 0;
6728        self.atlas_color.used_area = 0;
6729        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6730            label: Some("atlas color bind"),
6731            layout: &self.text_bind_layout,
6732            entries: &[
6733                wgpu::BindGroupEntry {
6734                    binding: 0,
6735                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6736                },
6737                wgpu::BindGroupEntry {
6738                    binding: 1,
6739                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6740                },
6741            ],
6742        });
6743        let survivors: Vec<_> = self.atlas_color.map.drain().collect();
6744        self.atlas_color_deferred.clear();
6745        for (key, previous) in survivors {
6746            let _ = self.upload_glyph_color(key.clone(), f32::from_bits(key.font_size_bits));
6747            if let Some(info) = self.atlas_color.map.get_mut(&key) {
6748                info.last_touch = previous.last_touch;
6749                info.last_used_frame = previous.last_used_frame;
6750            }
6751        }
6752    }
6753
6754    fn sync_text_caches(&mut self) {
6755        let generation = repose_text::font_generation();
6756        if generation == self.atlas_generation {
6757            return;
6758        }
6759        self.atlas_generation = generation;
6760        self.reset_atlas_storage();
6761        self.atlas_mask_failures.clear();
6762        self.atlas_color_failures.clear();
6763        self.glyph_outline_cache.clear();
6764        self.legacy_shaped_text_cache.clear();
6765        self.slug_draw_cache.clear();
6766        self.slug_draw_cache_bytes_total = 0;
6767        self.slug_cache = slug::GlyphSlugCache::new();
6768    }
6769
6770    fn shape_text_cached(
6771        &mut self,
6772        text: &Arc<str>,
6773        px: f32,
6774        line_height_ratio: f32,
6775        font_family: Option<&'static str>,
6776        font_weight: u16,
6777        font_style: u8,
6778        letter_spacing: f32,
6779        variation: Option<&Arc<str>>,
6780    ) -> Arc<repose_text::SharedShapedText> {
6781        repose_text::shape_line_cached(
6782            text.as_ref(),
6783            px,
6784            line_height_ratio,
6785            font_family,
6786            font_weight,
6787            font_style,
6788            letter_spacing,
6789            variation.map(AsRef::as_ref),
6790        )
6791    }
6792
6793    fn legacy_shape_text_cached(
6794        &mut self,
6795        text: &Arc<str>,
6796        px: f32,
6797        line_height_ratio: f32,
6798        font_family: Option<&'static str>,
6799        font_weight: u16,
6800        font_style: u8,
6801        letter_spacing: f32,
6802        variation: Option<&Arc<str>>,
6803    ) -> Arc<[repose_text::ShapedGlyph]> {
6804        let key = TextShapeKey {
6805            text: text.clone(),
6806            px_bits: px.to_bits(),
6807            line_height_bits: line_height_ratio.to_bits(),
6808            font_family,
6809            font_weight,
6810            font_style,
6811            letter_spacing_bits: letter_spacing.to_bits(),
6812            variation: variation.cloned(),
6813            font_generation: self.atlas_generation,
6814        };
6815        if let Some(entry) = self.legacy_shaped_text_cache.get_mut(&key) {
6816            entry.last_used_frame = self.frame_index;
6817            return entry.glyphs.clone();
6818        }
6819        let glyphs: Arc<[repose_text::ShapedGlyph]> = Arc::from(repose_text::shape_line(
6820            text.as_ref(),
6821            px,
6822            line_height_ratio,
6823            font_family,
6824            font_weight,
6825            font_style,
6826            letter_spacing,
6827            variation.map(AsRef::as_ref),
6828        ));
6829        self.legacy_shaped_text_cache.insert(
6830            key,
6831            TextShapeCacheEntry {
6832                glyphs: glyphs.clone(),
6833                last_used_frame: self.frame_index,
6834            },
6835        );
6836        while self.legacy_shaped_text_cache.len() > MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES {
6837            let Some(victim) = self
6838                .legacy_shaped_text_cache
6839                .iter()
6840                .min_by_key(|(_, entry)| entry.last_used_frame)
6841                .map(|(key, _)| key.clone())
6842            else {
6843                break;
6844            };
6845            self.legacy_shaped_text_cache.remove(&victim);
6846        }
6847        glyphs
6848    }
6849
6850    fn slug_draw_key(
6851        cache_key: &repose_text::CacheKey,
6852        fill: bool,
6853        stroke_key: Option<&slug::StrokeTessKey>,
6854        transform: &Transform,
6855        origin: (f32, f32),
6856        px: f32,
6857        target: (f32, f32),
6858        color: [f32; 4],
6859    ) -> SlugDrawKey {
6860        let linear = transform.linear();
6861        SlugDrawKey {
6862            cache_key: cache_key.clone(),
6863            fill,
6864            stroke: stroke_key.cloned(),
6865            transform: [
6866                linear[0].to_bits(),
6867                linear[1].to_bits(),
6868                linear[2].to_bits(),
6869                linear[3].to_bits(),
6870                transform.scale_x.to_bits(),
6871                transform.scale_y.to_bits(),
6872                transform.translate_x.to_bits(),
6873                transform.translate_y.to_bits(),
6874                transform.rotate.to_bits(),
6875                transform.shear_x.to_bits(),
6876                transform.shear_y.to_bits(),
6877            ],
6878            origin: [origin.0.to_bits(), origin.1.to_bits()],
6879            px_bits: px.to_bits(),
6880            target: [target.0.to_bits(), target.1.to_bits()],
6881            color: color.map(f32::to_bits),
6882        }
6883    }
6884
6885    fn evict_slug_draw_cache(&mut self) -> bool {
6886        let victim = self
6887            .slug_draw_cache
6888            .iter()
6889            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
6890            .min_by_key(|(_, entry)| entry.last_touch_frame)
6891            .map(|(key, _)| key.clone());
6892        let Some(victim) = victim else {
6893            return false;
6894        };
6895        let Some(entry) = self.slug_draw_cache.remove(&victim) else {
6896            return false;
6897        };
6898        self.slug_draw_cache_bytes_total =
6899            self.slug_draw_cache_bytes_total.saturating_sub(entry.bytes);
6900        true
6901    }
6902
6903    fn reserve_slug_draw_cache(&mut self, bytes: u64) -> bool {
6904        while self.slug_draw_cache.len() >= MAX_SLUG_DRAW_CACHE_ENTRIES
6905            || self.slug_draw_cache_bytes_total.saturating_add(bytes) > MAX_SLUG_DRAW_CACHE_BYTES
6906        {
6907            if !self.evict_slug_draw_cache() {
6908                return false;
6909            }
6910        }
6911        true
6912    }
6913
6914    fn get_cached_slug_vertices(&mut self, key: &SlugDrawKey) -> Option<Arc<[slug::TessVertex]>> {
6915        let touch = self.next_cache_touch();
6916        self.slug_draw_cache.get_mut(key).map(|entry| {
6917            entry.last_used_frame = self.frame_index;
6918            entry.last_touch_frame = touch;
6919            entry.vertices.clone()
6920        })
6921    }
6922
6923    fn cached_slug_vertices(
6924        &mut self,
6925        key: SlugDrawKey,
6926        vertices: Vec<slug::TessVertex>,
6927    ) -> Arc<[slug::TessVertex]> {
6928        let touch = self.next_cache_touch();
6929        if let Some(entry) = self.slug_draw_cache.get_mut(&key) {
6930            entry.last_used_frame = self.frame_index;
6931            entry.last_touch_frame = touch;
6932            return entry.vertices.clone();
6933        }
6934        let bytes = slug_draw_cache_entry_bytes(vertices.len());
6935        if bytes > MAX_SLUG_DRAW_CACHE_BYTES || !self.reserve_slug_draw_cache(bytes) {
6936            return Arc::from(vertices);
6937        }
6938        let touch = self.next_cache_touch();
6939        let vertices: Arc<[slug::TessVertex]> = Arc::from(vertices);
6940        self.slug_draw_cache.insert(
6941            key,
6942            SlugDrawCacheEntry {
6943                vertices: vertices.clone(),
6944                bytes,
6945                last_used_frame: self.frame_index,
6946                last_touch_frame: touch,
6947            },
6948        );
6949        self.slug_draw_cache_bytes_total = self.slug_draw_cache_bytes_total.saturating_add(bytes);
6950        vertices
6951    }
6952
6953    fn cached_glyph_outline(
6954        &mut self,
6955        cache_key: &repose_text::CacheKey,
6956    ) -> Option<Arc<[repose_text::Command]>> {
6957        let touch = self.next_cache_touch();
6958        if let Some(entry) = self.glyph_outline_cache.get_mut(cache_key) {
6959            entry.last_touch = touch;
6960            return entry.commands.clone();
6961        }
6962        let commands = repose_text::extract_outline_commands(cache_key).map(Arc::from);
6963        if self.glyph_outline_cache.len() >= MAX_GLYPH_OUTLINE_CACHE_ENTRIES {
6964            self.trim_glyph_outline_cache();
6965        }
6966        self.glyph_outline_cache.insert(
6967            cache_key.clone(),
6968            GlyphOutlineEntry {
6969                commands: commands.clone(),
6970                last_touch: touch,
6971            },
6972        );
6973        commands
6974    }
6975
6976    /// Drop the coldest outlines so a working set larger than the cap degrades
6977    /// into steady churn instead of a full rebuild on every miss.
6978    fn trim_glyph_outline_cache(&mut self) {
6979        let target = (MAX_GLYPH_OUTLINE_CACHE_ENTRIES / ATLAS_COMPACT_KEEP_DEN as usize)
6980            * ATLAS_COMPACT_KEEP_NUM as usize;
6981        let mut victims: Vec<(u64, repose_text::CacheKey)> = self
6982            .glyph_outline_cache
6983            .iter()
6984            .map(|(key, entry)| (entry.last_touch, key.clone()))
6985            .collect();
6986        victims.sort_unstable_by_key(|victim| victim.0);
6987        for (_, key) in victims {
6988            if self.glyph_outline_cache.len() <= target {
6989                break;
6990            }
6991            self.glyph_outline_cache.remove(&key);
6992        }
6993    }
6994
6995    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
6996        self.atlas_mask_bind.clone()
6997    }
6998
6999    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
7000        self.atlas_color_bind.clone()
7001    }
7002
7003    fn mark_atlas_mask_failure(&mut self, key: repose_text::CacheKey) {
7004        if self.atlas_mask_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
7005            self.atlas_mask_failures.clear();
7006        }
7007        self.atlas_mask_failures.insert(key);
7008    }
7009
7010    fn mark_atlas_color_failure(&mut self, key: repose_text::CacheKey) {
7011        if self.atlas_color_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
7012            self.atlas_color_failures.clear();
7013        }
7014        self.atlas_color_failures.insert(key);
7015    }
7016
7017    fn upload_glyph_mask(
7018        &mut self,
7019        cache_key: repose_text::CacheKey,
7020        px: f32,
7021    ) -> Option<GlyphInfo> {
7022        let px = quantize_glyph_px(px);
7023        let keyp = repose_text::CacheKey {
7024            font_size_bits: px.to_bits(),
7025            ..cache_key
7026        };
7027        let touch = self.next_cache_touch();
7028        if let Some(info) = self.atlas_mask.map.get_mut(&keyp) {
7029            info.last_touch = touch;
7030            info.last_used_frame = self.frame_index;
7031            return Some(*info);
7032        }
7033        if self.atlas_mask_failures.contains(&keyp) || self.atlas_mask_deferred.contains(&keyp) {
7034            return None;
7035        }
7036
7037        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7038            self.mark_atlas_mask_failure(keyp);
7039            return None;
7040        };
7041        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
7042            self.mark_atlas_mask_failure(keyp);
7043            return None;
7044        }
7045        let Some(coverage) = swash_to_a8_coverage(gb.content, &gb.data) else {
7046            self.mark_atlas_mask_failure(keyp);
7047            return None;
7048        };
7049
7050        let w = gb.w.max(1);
7051        let h = gb.h.max(1);
7052        let expected = checked_image_bytes_usize(w, h, 1).ok()?;
7053        if coverage.len() < expected {
7054            self.mark_atlas_mask_failure(keyp);
7055            return None;
7056        }
7057        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7058            self.mark_atlas_mask_failure(keyp);
7059            return None;
7060        }
7061        if !self.alloc_or_schedule_mask(w, h) {
7062            defer_atlas_glyph(&mut self.atlas_mask_deferred, keyp);
7063            return None;
7064        }
7065        let x = self.atlas_mask.next_x;
7066        let y = self.atlas_mask.next_y;
7067        self.atlas_mask.next_x += w + 1;
7068        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
7069
7070        let layout = wgpu::TexelCopyBufferLayout {
7071            offset: 0,
7072            bytes_per_row: Some(w),
7073            rows_per_image: Some(h),
7074        };
7075        let size = wgpu::Extent3d {
7076            width: w,
7077            height: h,
7078            depth_or_array_layers: 1,
7079        };
7080        self.queue.write_texture(
7081            wgpu::TexelCopyTextureInfoBase {
7082                texture: &self.atlas_mask.tex,
7083                mip_level: 0,
7084                origin: wgpu::Origin3d { x, y, z: 0 },
7085                aspect: wgpu::TextureAspect::All,
7086            },
7087            &coverage,
7088            layout,
7089            size,
7090        );
7091
7092        let info = GlyphInfo {
7093            u0: x as f32 / self.atlas_mask.size as f32,
7094            v0: y as f32 / self.atlas_mask.size as f32,
7095            u1: (x + w) as f32 / self.atlas_mask.size as f32,
7096            v1: (y + h) as f32 / self.atlas_mask.size as f32,
7097            w: w as f32,
7098            h: h as f32,
7099            last_touch: touch,
7100            last_used_frame: self.frame_index,
7101        };
7102        self.atlas_mask.used_area = self
7103            .atlas_mask
7104            .used_area
7105            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7106        self.atlas_mask.map.insert(keyp, info);
7107        Some(info)
7108    }
7109
7110    fn upload_glyph_color(
7111        &mut self,
7112        cache_key: repose_text::CacheKey,
7113        px: f32,
7114    ) -> Option<GlyphInfo> {
7115        let px = quantize_glyph_px(px);
7116        let keyp = repose_text::CacheKey {
7117            font_size_bits: px.to_bits(),
7118            ..cache_key
7119        };
7120        let touch = self.next_cache_touch();
7121        if let Some(info) = self.atlas_color.map.get_mut(&keyp) {
7122            info.last_touch = touch;
7123            info.last_used_frame = self.frame_index;
7124            return Some(*info);
7125        }
7126        if self.atlas_color_failures.contains(&keyp) || self.atlas_color_deferred.contains(&keyp) {
7127            return None;
7128        }
7129        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7130            self.mark_atlas_color_failure(keyp);
7131            return None;
7132        };
7133        if !matches!(gb.content, repose_text::SwashContent::Color) {
7134            self.mark_atlas_color_failure(keyp);
7135            return None;
7136        }
7137        let w = gb.w.max(1);
7138        let h = gb.h.max(1);
7139        let expected = checked_image_bytes_usize(w, h, 4).ok()?;
7140        if gb.data.len() < expected {
7141            self.mark_atlas_color_failure(keyp);
7142            return None;
7143        }
7144        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7145            self.mark_atlas_color_failure(keyp);
7146            return None;
7147        }
7148        if !self.alloc_or_schedule_color(w, h) {
7149            defer_atlas_glyph(&mut self.atlas_color_deferred, keyp);
7150            return None;
7151        }
7152        let x = self.atlas_color.next_x;
7153        let y = self.atlas_color.next_y;
7154        self.atlas_color.next_x += w + 1;
7155        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
7156
7157        let layout = wgpu::TexelCopyBufferLayout {
7158            offset: 0,
7159            bytes_per_row: Some(w * 4),
7160            rows_per_image: Some(h),
7161        };
7162        let size = wgpu::Extent3d {
7163            width: w,
7164            height: h,
7165            depth_or_array_layers: 1,
7166        };
7167        self.queue.write_texture(
7168            wgpu::TexelCopyTextureInfoBase {
7169                texture: &self.atlas_color.tex,
7170                mip_level: 0,
7171                origin: wgpu::Origin3d { x, y, z: 0 },
7172                aspect: wgpu::TextureAspect::All,
7173            },
7174            &gb.data,
7175            layout,
7176            size,
7177        );
7178        let info = GlyphInfo {
7179            u0: x as f32 / self.atlas_color.size as f32,
7180            v0: y as f32 / self.atlas_color.size as f32,
7181            u1: (x + w) as f32 / self.atlas_color.size as f32,
7182            v1: (y + h) as f32 / self.atlas_color.size as f32,
7183            w: w as f32,
7184            h: h as f32,
7185            last_touch: touch,
7186            last_used_frame: self.frame_index,
7187        };
7188        self.atlas_color.used_area = self
7189            .atlas_color
7190            .used_area
7191            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7192        self.atlas_color.map.insert(keyp, info);
7193        Some(info)
7194    }
7195
7196    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
7197        let Some(x_end) = self
7198            .atlas_mask
7199            .next_x
7200            .checked_add(w)
7201            .and_then(|v| v.checked_add(1))
7202        else {
7203            return false;
7204        };
7205        if x_end >= self.atlas_mask.size {
7206            self.atlas_mask.next_x = 1;
7207            let Some(next_y) = self
7208                .atlas_mask
7209                .next_y
7210                .checked_add(self.atlas_mask.row_h)
7211                .and_then(|v| v.checked_add(1))
7212            else {
7213                return false;
7214            };
7215            self.atlas_mask.next_y = next_y;
7216            self.atlas_mask.row_h = 0;
7217        }
7218        let Some(y_end) = self
7219            .atlas_mask
7220            .next_y
7221            .checked_add(h)
7222            .and_then(|v| v.checked_add(1))
7223        else {
7224            return false;
7225        };
7226        y_end < self.atlas_mask.size
7227    }
7228
7229    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
7230        let Some(x_end) = self
7231            .atlas_color
7232            .next_x
7233            .checked_add(w)
7234            .and_then(|v| v.checked_add(1))
7235        else {
7236            return false;
7237        };
7238        if x_end >= self.atlas_color.size {
7239            self.atlas_color.next_x = 1;
7240            let Some(next_y) = self
7241                .atlas_color
7242                .next_y
7243                .checked_add(self.atlas_color.row_h)
7244                .and_then(|v| v.checked_add(1))
7245            else {
7246                return false;
7247            };
7248            self.atlas_color.next_y = next_y;
7249            self.atlas_color.row_h = 0;
7250        }
7251        let Some(y_end) = self
7252            .atlas_color
7253            .next_y
7254            .checked_add(h)
7255            .and_then(|v| v.checked_add(1))
7256        else {
7257            return false;
7258        };
7259        y_end < self.atlas_color.size
7260    }
7261
7262    /// Place a `w` x `h` cell in the mask atlas, or schedule the work needed to
7263    /// make room. Growth and eviction both re-pack the atlas, which would move
7264    /// glyphs already batched into the frame in flight, so they are deferred to
7265    /// the next `begin_frame`; the caller treats a `false` as "skip this glyph".
7266    fn alloc_or_schedule_mask(&mut self, w: u32, h: u32) -> bool {
7267        if self.atlas_mask.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7268            || self
7269                .atlas_mask
7270                .used_area
7271                .saturating_add(u64::from(w) * u64::from(h))
7272                >= self.atlas_mask.size as u64 * self.atlas_mask.size as u64
7273        {
7274            self.atlas_mask_pending.compact = true;
7275            return false;
7276        }
7277        if self.alloc_space_mask(w, h) {
7278            return true;
7279        }
7280        if self.atlas_mask.size < MAX_GLYPH_ATLAS_SIZE {
7281            self.atlas_mask_pending.grow = true;
7282        } else {
7283            self.atlas_mask_pending.compact = true;
7284        }
7285        false
7286    }
7287
7288    /// See [`Self::alloc_or_schedule_mask`].
7289    fn alloc_or_schedule_color(&mut self, w: u32, h: u32) -> bool {
7290        if self.atlas_color.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7291            || self
7292                .atlas_color
7293                .used_area
7294                .saturating_add(u64::from(w) * u64::from(h))
7295                >= self.atlas_color.size as u64 * self.atlas_color.size as u64
7296        {
7297            self.atlas_color_pending.compact = true;
7298            return false;
7299        }
7300        if self.alloc_space_color(w, h) {
7301            return true;
7302        }
7303        if self.atlas_color.size < MAX_GLYPH_ATLAS_SIZE {
7304            self.atlas_color_pending.grow = true;
7305        } else {
7306            self.atlas_color_pending.compact = true;
7307        }
7308        false
7309    }
7310}
7311
7312/// Pack the shape brush enums into the single `bits` word the shaders read, so
7313/// they cost one flat varying instead of three locations.
7314fn pack_shape_bits(brush_type: u32, grad_kind: u32, tile_mode: u32, cap: u32) -> u32 {
7315    debug_assert!(brush_type < 4 && grad_kind < 4 && tile_mode < 4 && cap < 4);
7316    (brush_type & 3) | ((grad_kind & 3) << 2) | ((tile_mode & 3) << 4) | ((cap & 3) << 6)
7317}
7318
7319/// Packed brush fields shared by the shape instances (border, ellipse,
7320/// ellipse border, arc). Gradient endpoints are shape-local px; the shaders
7321/// recenter `(0,0)` at the shape top-left. Radial packs `center` into
7322/// `grad_p0` and `radius` into `grad_p1.x`; sweep packs `center` into
7323/// `grad_p0`.
7324///
7325/// `rect` is the shape's scene-space bounds and `transform` the accumulated
7326/// scene transform.
7327/// Endpoints are converted from shape-local px through the inverse linear
7328/// part so rotation and uniform scale cancel against the shader's
7329/// un-rotation. Non-uniform scale and shear distort the gradient the same
7330/// way they distort the shape (the shader un-rotates but cannot un-scale
7331/// pixels).
7332fn brush_to_shape_fields(
7333    brush: &Brush,
7334    rect: &repose_core::Rect,
7335    transform: &Transform,
7336) -> (u32, u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2], u32) {
7337    let to_local = |p: Vec2| {
7338        let m = transform.linear();
7339        let det = m[0] * m[3] - m[1] * m[2];
7340        if det.abs() < 1e-12 {
7341            return [p.x, p.y];
7342        }
7343        [
7344            (m[3] * p.x - m[1] * p.y) / det,
7345            (-m[2] * p.x + m[0] * p.y) / det,
7346        ]
7347    };
7348    match brush {
7349        Brush::Solid(c) => (
7350            0u32,
7351            0u32,
7352            c.to_linear(),
7353            [0.0; 4],
7354            [0.0; 2],
7355            [0.0; 2],
7356            0u32,
7357        ),
7358        Brush::Linear {
7359            start,
7360            end,
7361            start_color,
7362            end_color,
7363        } => (
7364            1u32,
7365            0u32,
7366            start_color.to_linear(),
7367            end_color.to_linear(),
7368            to_local(*start),
7369            to_local(*end),
7370            0u32,
7371        ),
7372        Brush::LinearNormalized {
7373            start,
7374            end,
7375            start_color,
7376            end_color,
7377        } => (
7378            1u32,
7379            0u32,
7380            start_color.to_linear(),
7381            end_color.to_linear(),
7382            to_local(Vec2 {
7383                x: start.x * rect.w,
7384                y: start.y * rect.h,
7385            }),
7386            to_local(Vec2 {
7387                x: end.x * rect.w,
7388                y: end.y * rect.h,
7389            }),
7390            0u32,
7391        ),
7392        Brush::Radial {
7393            center,
7394            radius,
7395            start_color,
7396            end_color,
7397        } => (
7398            1u32,
7399            1u32,
7400            start_color.to_linear(),
7401            end_color.to_linear(),
7402            to_local(*center),
7403            [radius.max(0.0), 0.0],
7404            0u32,
7405        ),
7406        Brush::Sweep {
7407            center,
7408            start_color,
7409            end_color,
7410        } => (
7411            1u32,
7412            2u32,
7413            start_color.to_linear(),
7414            end_color.to_linear(),
7415            to_local(*center),
7416            [0.0, 0.0],
7417            0u32,
7418        ),
7419        _ => (0u32, 0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2], 0u32),
7420    }
7421}
7422
7423fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
7424    match brush {
7425        Brush::Solid(c) => (
7426            0u32,
7427            c.to_linear(),
7428            [0.0, 0.0, 0.0, 0.0],
7429            [0.0, 0.0],
7430            [0.0, 1.0],
7431        ),
7432        Brush::Linear {
7433            start,
7434            end,
7435            start_color,
7436            end_color,
7437        } => (
7438            1u32,
7439            start_color.to_linear(),
7440            end_color.to_linear(),
7441            [start.x, start.y],
7442            [end.x, end.y],
7443        ),
7444        Brush::LinearNormalized {
7445            start,
7446            end,
7447            start_color,
7448            end_color,
7449        } => (
7450            1u32,
7451            start_color.to_linear(),
7452            end_color.to_linear(),
7453            [start.x, start.y],
7454            [end.x, end.y],
7455        ),
7456        Brush::Radial { start_color, .. } => (
7457            0u32,
7458            start_color.to_linear(),
7459            [0.0, 0.0, 0.0, 0.0],
7460            [0.0, 0.0],
7461            [0.0, 1.0],
7462        ),
7463        Brush::Sweep { start_color, .. } => (
7464            0u32,
7465            start_color.to_linear(),
7466            [0.0, 0.0, 0.0, 0.0],
7467            [0.0, 0.0],
7468            [0.0, 1.0],
7469        ),
7470        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
7471    }
7472}
7473
7474/// Fallback color when a [`Brush`] reaches a solid-only path (glyph atlas
7475/// uploads for gradient text). Uses the gradient's start color.
7476#[allow(dead_code)]
7477fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
7478    match brush {
7479        Brush::Solid(c) => c.to_linear(),
7480        Brush::Linear { start_color, .. } => start_color.to_linear(),
7481        Brush::LinearNormalized { start_color, .. } => start_color.to_linear(),
7482        Brush::Radial { start_color, .. } => start_color.to_linear(),
7483        Brush::Sweep { start_color, .. } => start_color.to_linear(),
7484        _ => [0.0; 4],
7485    }
7486}
7487
7488fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
7489    let size = 1024u32;
7490    let tex = device.create_texture(&wgpu::TextureDescriptor {
7491        label: Some("glyph atlas A8"),
7492        size: wgpu::Extent3d {
7493            width: size,
7494            height: size,
7495            depth_or_array_layers: 1,
7496        },
7497        mip_level_count: 1,
7498        sample_count: 1,
7499        dimension: wgpu::TextureDimension::D2,
7500        format: wgpu::TextureFormat::R8Unorm,
7501        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7502        view_formats: &[],
7503    });
7504    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7505    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7506        label: Some("glyph atlas sampler A8"),
7507        address_mode_u: wgpu::AddressMode::ClampToEdge,
7508        address_mode_v: wgpu::AddressMode::ClampToEdge,
7509        address_mode_w: wgpu::AddressMode::ClampToEdge,
7510        mag_filter: wgpu::FilterMode::Linear,
7511        min_filter: wgpu::FilterMode::Linear,
7512        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7513        ..Default::default()
7514    });
7515
7516    AtlasA8 {
7517        tex,
7518        view,
7519        sampler,
7520        size,
7521        next_x: 1,
7522        next_y: 1,
7523        row_h: 0,
7524        map: HashMap::new(),
7525        used_area: 0,
7526    }
7527}
7528
7529fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
7530    let size = 1024u32;
7531    let tex = device.create_texture(&wgpu::TextureDescriptor {
7532        label: Some("glyph atlas RGBA"),
7533        size: wgpu::Extent3d {
7534            width: size,
7535            height: size,
7536            depth_or_array_layers: 1,
7537        },
7538        mip_level_count: 1,
7539        sample_count: 1,
7540        dimension: wgpu::TextureDimension::D2,
7541        format: wgpu::TextureFormat::Rgba8UnormSrgb,
7542        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7543        view_formats: &[],
7544    });
7545    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7546    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7547        label: Some("glyph atlas sampler RGBA"),
7548        address_mode_u: wgpu::AddressMode::ClampToEdge,
7549        address_mode_v: wgpu::AddressMode::ClampToEdge,
7550        address_mode_w: wgpu::AddressMode::ClampToEdge,
7551        mag_filter: wgpu::FilterMode::Linear,
7552        min_filter: wgpu::FilterMode::Linear,
7553        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7554        ..Default::default()
7555    });
7556    AtlasRGBA {
7557        tex,
7558        view,
7559        sampler,
7560        size,
7561        next_x: 1,
7562        next_y: 1,
7563        row_h: 0,
7564        map: HashMap::new(),
7565        used_area: 0,
7566    }
7567}
7568
7569#[cfg(feature = "winit-surface")]
7570impl WgpuSurfaceBackend {
7571    /// Drop the surface, keeping device/queue/pipelines. Releases the old
7572    /// native-window binding. Call `recreate_surface` to present again.
7573    pub fn take_surface(&mut self) -> Option<wgpu::Surface<'static>> {
7574        self.surface.take()
7575    }
7576
7577    /// Create a fresh surface for `window` on the retained instance and
7578    /// configure it. Recovers from `CurrentSurfaceTexture::Lost`, where
7579    /// reconfiguring the old surface object cannot help.
7580    pub fn recreate_surface(&mut self, window: &Arc<winit::window::Window>) -> anyhow::Result<()> {
7581        self.window = Some(window.clone());
7582        let size = window.inner_size();
7583        if size.width == 0 || size.height == 0 {
7584            self.surface = None;
7585            self.pending_reconfigure = false;
7586            self.renderer.resize(0, 0);
7587            anyhow::bail!("window has zero size; surface recreation deferred");
7588        }
7589        let Some(instance) = self.instance.as_ref() else {
7590            anyhow::bail!("no wgpu instance retained; cannot recreate surface")
7591        };
7592        let Some(config) = self.surface_config.as_mut() else {
7593            anyhow::bail!("no surface config retained; cannot recreate surface")
7594        };
7595        let (width, height) = clamp_output_size(
7596            size.width,
7597            size.height,
7598            self.renderer.device.limits().max_texture_dimension_2d,
7599        );
7600        config.width = width;
7601        config.height = height;
7602        let surface = instance.create_surface(window.clone())?;
7603        surface.configure(&self.renderer.device, config);
7604        self.surface = Some(surface);
7605        self.pending_reconfigure = false;
7606        self.renderer.resize(width, height);
7607        Ok(())
7608    }
7609}
7610
7611#[cfg(feature = "winit-surface")]
7612impl RenderBackend for WgpuSurfaceBackend {
7613    fn configure_surface(&mut self, width: u32, height: u32) -> (u32, u32) {
7614        if width == 0 || height == 0 {
7615            self.renderer.resize(0, 0);
7616            self.pending_reconfigure = true;
7617            return (0, 0);
7618        }
7619        let (width, height) = clamp_output_size(
7620            width,
7621            height,
7622            self.renderer.device.limits().max_texture_dimension_2d,
7623        );
7624        if self.renderer.output_width == width && self.renderer.output_height == height {
7625            if let Some(ref mut config) = self.surface_config {
7626                config.width = width;
7627                config.height = height;
7628            }
7629            return (width, height);
7630        }
7631        if let Some(ref mut config) = self.surface_config {
7632            config.width = width;
7633            config.height = height;
7634        }
7635        self.renderer.resize(width, height);
7636        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref())
7637        {
7638            surface.configure(&self.renderer.device, config);
7639        }
7640        (width, height)
7641    }
7642
7643    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) -> bool {
7644        if self.pending_reconfigure {
7645            if let (Some(surface), Some(config)) =
7646                (self.surface.as_ref(), self.surface_config.as_ref())
7647            {
7648                surface.configure(&self.renderer.device, config);
7649            }
7650            self.pending_reconfigure = false;
7651        }
7652
7653        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
7654            request_frame();
7655            return false;
7656        }
7657        if let Err(error) = self.renderer.begin_frame() {
7658            log::warn!("begin renderer frame: {error:#}");
7659            request_frame();
7660            return false;
7661        }
7662
7663        let Some(surface) = self.surface.as_ref() else {
7664            self.renderer.end_frame();
7665            request_frame();
7666            return false;
7667        };
7668        let frame = match surface.get_current_texture() {
7669            wgpu::CurrentSurfaceTexture::Success(f) => f,
7670            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
7671                self.pending_reconfigure = true;
7672                f
7673            }
7674            other @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost) => {
7675                self.surface = None;
7676                let result = self
7677                    .window
7678                    .clone()
7679                    .ok_or_else(|| anyhow::anyhow!("no window retained for surface recreation"))
7680                    .and_then(|window| self.recreate_surface(&window));
7681                if let Err(error) = result {
7682                    log::warn!("surface {other:?}; recreation deferred: {error:#}");
7683                }
7684                self.renderer.end_frame();
7685                request_frame();
7686                return false;
7687            }
7688            other => {
7689                match other {
7690                    wgpu::CurrentSurfaceTexture::Validation => {
7691                        log::warn!("surface {other:?}; reconfiguring next frame");
7692                        self.pending_reconfigure = true;
7693                    }
7694                    wgpu::CurrentSurfaceTexture::Timeout
7695                    | wgpu::CurrentSurfaceTexture::Occluded => {
7696                        log::debug!("surface {other:?}; retrying next frame");
7697                    }
7698                    _ => {}
7699                }
7700                self.renderer.end_frame();
7701                request_frame();
7702                return false;
7703            }
7704        };
7705
7706        // The swapchain extent and the renderer's size are updated from
7707        // separate events, so they can disagree for a frame mid-resize (most
7708        // visibly when dragging a window into a portrait shape). With MSAA on,
7709        // a color attachment whose extent differs from its resolve target
7710        // fails wgpu validation, which drops the whole pass *including its
7711        // clear*, so the just-reconfigured swapchain keeps undefined contents
7712        // and presents as green/black streaks. Adopt the swapchain's size and
7713        // retry next frame instead of rendering an invalid pass.
7714        let (swap_w, swap_h) = (frame.texture.width(), frame.texture.height());
7715        if swap_w != self.renderer.output_width || swap_h != self.renderer.output_height {
7716            log::debug!(
7717                "swapchain {swap_w}x{swap_h} != renderer {}x{}; resyncing",
7718                self.renderer.output_width,
7719                self.renderer.output_height
7720            );
7721            drop(frame);
7722            if let Some(config) = self.surface_config.as_mut() {
7723                config.width = swap_w;
7724                config.height = swap_h;
7725            }
7726            self.renderer.resize(swap_w, swap_h);
7727            self.pending_reconfigure = true;
7728            self.renderer.end_frame();
7729            request_frame();
7730            return false;
7731        }
7732
7733        let swap_view = if let Some(view_format) = self
7734            .surface_config
7735            .as_ref()
7736            .and_then(|c| c.view_formats.iter().find(|f| f.is_srgb()).copied())
7737        {
7738            frame.texture.create_view(&wgpu::TextureViewDescriptor {
7739                format: Some(view_format),
7740                ..Default::default()
7741            })
7742        } else {
7743            frame
7744                .texture
7745                .create_view(&wgpu::TextureViewDescriptor::default())
7746        };
7747        let mut encoder =
7748            self.renderer
7749                .device
7750                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7751                    label: Some("frame encoder"),
7752                });
7753
7754        self.renderer.render_scene_to_encoder_with_texture(
7755            scene,
7756            &mut encoder,
7757            &swap_view,
7758            Some(&frame.texture),
7759            None,
7760        );
7761
7762        //NOTE: The WebGL HAL present path (fullscreen triangle / blit) does not
7763        // restore gl.colorMask. Hence this is needed to prevent frames from going transparent.
7764        {
7765            let _reset = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
7766                label: Some("webgl color_mask reset before present"),
7767                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7768                    view: &swap_view,
7769                    resolve_target: None,
7770                    ops: wgpu::Operations {
7771                        load: wgpu::LoadOp::Load,
7772                        store: wgpu::StoreOp::Store,
7773                    },
7774                    depth_slice: None,
7775                })],
7776                depth_stencil_attachment: None,
7777                timestamp_writes: None,
7778                occlusion_query_set: None,
7779                multiview_mask: None,
7780            });
7781        }
7782
7783        self.renderer
7784            .queue
7785            .submit(std::iter::once(encoder.finish()));
7786        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
7787            log::warn!("queue.present panicked: {:?}", e);
7788            self.renderer.end_frame();
7789            request_frame();
7790            return false;
7791        }
7792        self.renderer.end_frame();
7793        true
7794    }
7795}
7796
7797impl WgpuSceneRenderer {
7798    /// Open a translator-owned flatten layer for a perspective `PushTransform`.
7799    ///
7800    /// True perspective cannot ride the affine instance fast path, so the
7801    /// subtree renders flat into an offscreen layer and is composited back
7802    /// projectively on the matching pop (CSS-style flattening). The layer
7803    /// rect is the currently visible scissor in this target: content outside
7804    /// it is invisible in the parent, so clipping it in the layer changes
7805    /// nothing. Children keep the node's affine part on the stack (so
7806    /// `combine` stays affine-only) plus a layer-local shift, exactly like
7807    /// producer-owned blur layers - which is exact under rigid ancestors
7808    /// (translations commute) and the documented layer contract otherwise.
7809    #[allow(clippy::too_many_arguments)]
7810    fn replay_active_clips(
7811        &mut self,
7812        clips: &[ActiveClip],
7813        parent_transform: &Transform,
7814        origin: (f32, f32),
7815        target_size: (f32, f32),
7816        pass: &mut Pass,
7817        encoder: &mut wgpu::CommandEncoder,
7818    ) -> Vec<ActiveClip> {
7819        let mut current = repose_core::Rect {
7820            x: 0.0,
7821            y: 0.0,
7822            w: target_size.0,
7823            h: target_size.1,
7824        };
7825        let mut replayed = Vec::with_capacity(clips.len());
7826        for (index, clip) in clips.iter().enumerate() {
7827            let has_inverse = clips[..index].iter().any(ActiveClip::difference);
7828            let mut blocked = clip.blocked();
7829            if has_inverse && !blocked {
7830                log::error!(
7831                    "unsupported clip nesting: a Difference clip cannot contain another clip"
7832                );
7833                blocked = true;
7834            }
7835            let difference = clip.difference();
7836            let mut command = None;
7837            match clip {
7838                ActiveClip::Rect {
7839                    off: _,
7840                    cnt: _,
7841                    rect,
7842                    radii,
7843                    ..
7844                } => {
7845                    let local_rect = parent_rect_to_layer_rect(*rect, parent_transform, origin)
7846                        .unwrap_or(repose_core::Rect {
7847                            x: 0.0,
7848                            y: 0.0,
7849                            w: 0.0,
7850                            h: 0.0,
7851                        });
7852                    let local_radii =
7853                        parent_radii_to_layer_radii(*radii, parent_transform).unwrap_or([0.0; 4]);
7854                    let next = if difference {
7855                        current
7856                    } else {
7857                        intersect(current, local_rect)
7858                    };
7859                    let scissor = rect_to_scissor(
7860                        next,
7861                        target_size.0.max(1.0) as u32,
7862                        target_size.1.max(1.0) as u32,
7863                    );
7864                    let has_area = local_rect.x.is_finite()
7865                        && local_rect.y.is_finite()
7866                        && local_rect.w.is_finite()
7867                        && local_rect.h.is_finite()
7868                        && local_rect.w > 0.0
7869                        && local_rect.h > 0.0;
7870                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7871                        blocked = blocked || !difference;
7872                    } else {
7873                        let ndc = rect_to_ndc(local_rect, target_size.0, target_size.1);
7874                        let instance = ClipInstance {
7875                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
7876                            radii: local_radii,
7877                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
7878                        };
7879                        let bytes = bytemuck::bytes_of(&instance);
7880                        if self
7881                            .clip_ring
7882                            .grow_to_fit(
7883                                &self.device,
7884                                encoder,
7885                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
7886                            )
7887                            .is_ok()
7888                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
7889                        {
7890                            command = Some(Cmd::ClipPush {
7891                                off: new_off,
7892                                cnt: 1,
7893                                scissor,
7894                                difference,
7895                                applied: true,
7896                            });
7897                            replayed.push(ActiveClip::Rect {
7898                                off: new_off,
7899                                cnt: 1,
7900                                rect: local_rect,
7901                                radii: local_radii,
7902                                scissor,
7903                                difference,
7904                                applied: true,
7905                                blocked: false,
7906                            });
7907                        } else {
7908                            blocked = blocked || !difference;
7909                        }
7910                    }
7911                    current = next;
7912                }
7913                ActiveClip::Vector {
7914                    mesh,
7915                    source,
7916                    affine,
7917                    ..
7918                } => {
7919                    let local_affine =
7920                        parent_affine_to_layer_affine(*affine, parent_transform, origin)
7921                            .unwrap_or([0.0; 6]);
7922                    let aabb = mesh_aabb(source, local_affine);
7923                    let next = if difference {
7924                        current
7925                    } else {
7926                        intersect(current, aabb)
7927                    };
7928                    let scissor = rect_to_scissor(
7929                        next,
7930                        target_size.0.max(1.0) as u32,
7931                        target_size.1.max(1.0) as u32,
7932                    );
7933                    let has_area = aabb.x.is_finite()
7934                        && aabb.y.is_finite()
7935                        && aabb.w.is_finite()
7936                        && aabb.h.is_finite()
7937                        && aabb.w > 0.0
7938                        && aabb.h > 0.0
7939                        && mesh
7940                            .as_ref()
7941                            .is_some_and(|mesh| mesh.vertex_count > 0 && mesh.index_count > 0);
7942                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7943                        blocked = blocked || !difference;
7944                    } else if let Some(mesh_gpu) = mesh.clone()
7945                        && let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
7946                            &repose_core::PaintDesc::Solid,
7947                        ))
7948                        && let Some((ioff, icnt)) = self.alloc_mesh_instances(&[local_affine])
7949                    {
7950                        command = Some(Cmd::VectorClipPush {
7951                            mesh: mesh_gpu.clone(),
7952                            uoff,
7953                            ioff,
7954                            icnt,
7955                            scissor,
7956                            difference,
7957                            applied: true,
7958                        });
7959                        replayed.push(ActiveClip::Vector {
7960                            mesh: Some(mesh_gpu),
7961                            source: source.clone(),
7962                            uoff,
7963                            ioff,
7964                            icnt,
7965                            affine: local_affine,
7966                            scissor,
7967                            difference,
7968                            applied: true,
7969                            blocked: false,
7970                        });
7971                    } else {
7972                        blocked = blocked || !difference;
7973                    }
7974                    current = next;
7975                }
7976            }
7977            if blocked {
7978                current = repose_core::Rect {
7979                    x: 0.0,
7980                    y: 0.0,
7981                    w: 0.0,
7982                    h: 0.0,
7983                };
7984            }
7985            if command.is_none() {
7986                replayed.push(match clip {
7987                    ActiveClip::Rect {
7988                        rect,
7989                        radii,
7990                        scissor,
7991                        difference,
7992                        ..
7993                    } => ActiveClip::Rect {
7994                        off: 0,
7995                        cnt: 0,
7996                        rect: *rect,
7997                        radii: *radii,
7998                        scissor: *scissor,
7999                        difference: *difference,
8000                        applied: false,
8001                        blocked,
8002                    },
8003                    ActiveClip::Vector {
8004                        mesh,
8005                        source,
8006                        affine,
8007                        scissor,
8008                        difference,
8009                        ..
8010                    } => ActiveClip::Vector {
8011                        mesh: mesh.clone(),
8012                        source: source.clone(),
8013                        uoff: 0,
8014                        ioff: 0,
8015                        icnt: 0,
8016                        affine: parent_affine_to_layer_affine(*affine, parent_transform, origin)
8017                            .unwrap_or([0.0; 6]),
8018                        scissor: *scissor,
8019                        difference: *difference,
8020                        applied: false,
8021                        blocked,
8022                    },
8023                });
8024            }
8025            if let Some(command) = command {
8026                pass.cmds.push(command);
8027            }
8028            pass.active_scissor = if blocked {
8029                None
8030            } else {
8031                let scissor = rect_to_scissor(
8032                    current,
8033                    target_size.0.max(1.0) as u32,
8034                    target_size.1.max(1.0) as u32,
8035                );
8036                (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
8037            };
8038        }
8039        replayed
8040    }
8041
8042    #[allow(clippy::too_many_arguments)]
8043    fn push_perspective_layer(
8044        &mut self,
8045        node: Transform,
8046        top: Transform,
8047        transform_stack: &mut Vec<Transform>,
8048        scissor_stack: &mut Vec<repose_core::Rect>,
8049        root_clip_rect: &mut repose_core::Rect,
8050        current_target_size: &mut (f32, f32),
8051        current_pass: &mut Pass,
8052        active_clips: &mut Vec<ActiveClip>,
8053        passes: &mut Vec<Pass>,
8054        target_stack: &mut Vec<PassTarget>,
8055        flatten_stack: &mut Vec<FlattenRecord>,
8056        id_head: &mut u32,
8057        pass_id_head: &mut u64,
8058        ids_used: &mut Vec<u32>,
8059        encoder: &mut wgpu::CommandEncoder,
8060    ) {
8061        // Full projective map: affine ancestors over the node's map.
8062        // Ancestors are affine by construction (perspective always flattens
8063        // at push, and only stripped affines reach the stack).
8064        let map =
8065            Transform::compose_projective(&top.projective_matrix(), &node.projective_matrix());
8066        let scr = scissor_stack.last().copied().unwrap_or(*root_clip_rect);
8067        let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
8068        if !scr.w.is_finite()
8069            || !scr.h.is_finite()
8070            || scr.w < 1.0
8071            || scr.h < 1.0
8072            || scr.w > max_dimension
8073            || scr.h > max_dimension
8074        {
8075            return;
8076        }
8077        let w = scr.w.ceil();
8078        let h = scr.h.ceil();
8079        let layer_rect = repose_core::Rect {
8080            x: scr.x,
8081            y: scr.y,
8082            w,
8083            h,
8084        };
8085        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8086            return;
8087        }
8088        let layer_id = *id_head;
8089        *id_head = id_head.wrapping_add(1);
8090        ids_used.push(layer_id);
8091
8092        let stack_len = transform_stack.len();
8093        // Children render with the ancestors' map only: the node's own
8094        // affine part lives in `map` and applies once, at composite time.
8095        // (Pushing the stripped affine here too would foreshorten twice.)
8096        transform_stack.push(top);
8097        transform_stack.push(Transform::translate(-layer_rect.x, -layer_rect.y));
8098
8099        let saved_scissor_stack = std::mem::replace(
8100            scissor_stack,
8101            vec![repose_core::Rect {
8102                x: 0.0,
8103                y: 0.0,
8104                w,
8105                h,
8106            }],
8107        );
8108        let saved_root = std::mem::replace(
8109            root_clip_rect,
8110            repose_core::Rect {
8111                x: 0.0,
8112                y: 0.0,
8113                w,
8114                h,
8115            },
8116        );
8117        let saved_size = std::mem::replace(current_target_size, (w, h));
8118        let prev_target = current_pass.target;
8119        let saved_active_scissor = current_pass.active_scissor;
8120        let saved_clips = std::mem::take(active_clips);
8121        let layer_pass_id = *pass_id_head;
8122        *pass_id_head += 1;
8123        let mut layer_pass = Pass {
8124            id: layer_pass_id,
8125            target: PassTarget::Layer(layer_id),
8126            initial_scissor: (0, 0, w as u32, h as u32),
8127            active_scissor: Some((0, 0, w as u32, h as u32)),
8128            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8129            cmds: Vec::new(),
8130        };
8131        let layer_clips = self.replay_active_clips(
8132            &saved_clips,
8133            &top,
8134            (layer_rect.x, layer_rect.y),
8135            (w, h),
8136            &mut layer_pass,
8137            encoder,
8138        );
8139        *active_clips = layer_clips;
8140        let saved = std::mem::replace(current_pass, layer_pass);
8141        passes.push(saved);
8142        target_stack.push(prev_target);
8143        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8144        *current_target_size = (w, h);
8145        flatten_stack.push(FlattenRecord {
8146            stack_len,
8147            layer_id,
8148            map,
8149            layer_rect,
8150            saved_scissor_stack,
8151            saved_root,
8152            saved_size,
8153            saved_active_scissor,
8154            saved_clips,
8155        });
8156    }
8157
8158    /// Close a flatten layer: restore the parent target and composite the
8159    /// layer texture through the recorded projective map.
8160    #[allow(clippy::too_many_arguments)]
8161    fn pop_perspective_layer(
8162        &mut self,
8163        rec: FlattenRecord,
8164        scissor_stack: &mut Vec<repose_core::Rect>,
8165        root_clip_rect: &mut repose_core::Rect,
8166        current_target_size: &mut (f32, f32),
8167        current_pass: &mut Pass,
8168        active_clips: &mut Vec<ActiveClip>,
8169        passes: &mut Vec<Pass>,
8170        target_stack: &mut Vec<PassTarget>,
8171        pass_id_head: &mut u64,
8172        encoder: &mut wgpu::CommandEncoder,
8173    ) {
8174        *scissor_stack = rec.saved_scissor_stack;
8175        *root_clip_rect = rec.saved_root;
8176        *current_target_size = rec.saved_size;
8177        *active_clips = rec.saved_clips;
8178        let resumed_pass_id = *pass_id_head;
8179        *pass_id_head += 1;
8180        let saved = std::mem::replace(
8181            current_pass,
8182            Pass {
8183                id: resumed_pass_id,
8184                target: target_stack.pop().unwrap_or(PassTarget::Surface),
8185                initial_scissor: rec.saved_active_scissor.unwrap_or((0, 0, 1, 1)),
8186                active_scissor: rec.saved_active_scissor,
8187                clear_color: None,
8188                cmds: Vec::new(),
8189            },
8190        );
8191        passes.push(saved);
8192
8193        // Project the layer-rect corners (parent space) to NDC in the
8194        // resumed (parent) target, keeping each corner's homogeneous w for
8195        // perspective-correct sampling.
8196        let (tw, th) = rec.saved_size;
8197        let r = rec.layer_rect;
8198        let corners = [
8199            (r.x, r.y),
8200            (r.x + r.w, r.y),
8201            (r.x + r.w, r.y + r.h),
8202            (r.x, r.y + r.h),
8203        ];
8204        let mut ndc = [[0.0f32; 2]; 4];
8205        let mut ws = [1.0f32; 4];
8206        let mut all_behind = true;
8207        for (i, (x, y)) in corners.iter().enumerate() {
8208            let w_raw = rec.map[6] * x + rec.map[7] * y + rec.map[8];
8209            let w = if w_raw.abs() < 1e-6 {
8210                if w_raw < 0.0 { -1e-6 } else { 1e-6 }
8211            } else {
8212                w_raw
8213            };
8214            if w > 0.0 {
8215                all_behind = false;
8216            }
8217            let px = (rec.map[0] * x + rec.map[1] * y + rec.map[2]) / w;
8218            let py = (rec.map[3] * x + rec.map[4] * y + rec.map[5]) / w;
8219            ndc[i] = [px / tw * 2.0 - 1.0, 1.0 - py / th * 2.0];
8220            ws[i] = w;
8221        }
8222        if all_behind {
8223            // Entire subtree behind the viewer: nothing to composite (the
8224            // layer pass still ran, but its output is correctly discarded).
8225            return;
8226        }
8227        let Some(layer) = self.layer_pool.get(&rec.layer_id) else {
8228            return;
8229        };
8230        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
8231        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
8232        let inst = ProjectiveInstance {
8233            c0: ndc[0],
8234            c1: ndc[1],
8235            c2: ndc[2],
8236            c3: ndc[3],
8237            uv: [0.0, 0.0, uv_u1, uv_v1],
8238            w: ws,
8239            alpha: 1.0,
8240            _pad: [0.0; 3],
8241        };
8242        if self
8243            .projective_ring
8244            .grow_to_fit(
8245                &self.device,
8246                encoder,
8247                std::mem::size_of::<ProjectiveInstance>() as u64,
8248            )
8249            .is_err()
8250        {
8251            return;
8252        }
8253        let bytes = bytemuck::bytes_of(&inst);
8254        let Ok(off) = self.projective_ring.alloc_write(&self.queue, bytes) else {
8255            return;
8256        };
8257        current_pass.cmds.push(Cmd::CompositeProjective {
8258            off,
8259            cnt: 1,
8260            layer_id: rec.layer_id,
8261        });
8262    }
8263
8264    fn mesh_cache_key(mesh: &Arc<repose_core::VectorMeshData>) -> MeshCacheKey {
8265        MeshCacheKey {
8266            source: Arc::as_ptr(mesh) as usize,
8267            index: 0,
8268            kind: 1,
8269            vertex_len: mesh.vertices.len(),
8270            index_len: mesh.indices.len(),
8271        }
8272    }
8273
8274    fn overlay_mesh_cache_key(
8275        meshes: &Arc<[repose_core::VectorMeshData]>,
8276        index: usize,
8277    ) -> MeshCacheKey {
8278        let mesh = &meshes[index];
8279        MeshCacheKey {
8280            source: meshes.as_ptr() as usize,
8281            index,
8282            kind: 2,
8283            vertex_len: mesh.vertices.len(),
8284            index_len: mesh.indices.len(),
8285        }
8286    }
8287
8288    fn mesh_owner_matches(owner: &MeshOwner, other: &MeshOwner) -> bool {
8289        match (owner, other) {
8290            (MeshOwner::Object(a), MeshOwner::Object(b)) => Arc::ptr_eq(a, b),
8291            (MeshOwner::Overlay(a), MeshOwner::Overlay(b)) => Arc::ptr_eq(a, b),
8292            _ => false,
8293        }
8294    }
8295
8296    fn evict_mesh_cache(&mut self) -> bool {
8297        let victim = self
8298            .mesh_cache
8299            .iter()
8300            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
8301            .min_by_key(|(_, entry)| entry.last_touch_frame)
8302            .map(|(key, _)| *key);
8303        let Some(victim) = victim else {
8304            return false;
8305        };
8306        let Some(entry) = self.mesh_cache.remove(&victim) else {
8307            return false;
8308        };
8309        self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8310        true
8311    }
8312
8313    fn reserve_mesh_cache(&mut self, bytes: u64) -> bool {
8314        while self.mesh_cache.len() >= MAX_MESH_CACHE_ENTRIES
8315            || self.mesh_cache_bytes_total.saturating_add(bytes) > MAX_MESH_CACHE_BYTES
8316        {
8317            if !self.evict_mesh_cache() {
8318                return false;
8319            }
8320        }
8321        true
8322    }
8323
8324    fn reserve_mesh_gpu_budget(&mut self, bytes: u64) -> bool {
8325        while self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes {
8326            if !self.evict_mesh_cache() {
8327                return false;
8328            }
8329        }
8330        true
8331    }
8332
8333    fn upload_mesh_geometry_with_owner(
8334        &mut self,
8335        mesh: &repose_core::VectorMeshData,
8336        key: MeshCacheKey,
8337        owner: MeshOwner,
8338    ) -> Option<MeshGpu> {
8339        let touch = self.next_cache_touch();
8340        let cached = self
8341            .mesh_cache
8342            .get(&key)
8343            .filter(|entry| Self::mesh_owner_matches(&entry.owner, &owner))
8344            .map(|entry| entry.gpu.clone());
8345        if let Some(gpu) = cached {
8346            if let Some(entry) = self.mesh_cache.get_mut(&key) {
8347                entry.last_used_frame = self.frame_index;
8348                entry.last_touch_frame = touch;
8349            }
8350            return Some(gpu);
8351        }
8352        if let Some(entry) = self.mesh_cache.remove(&key) {
8353            self.mesh_cache_bytes_total =
8354                self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8355        }
8356        if mesh.vertices.is_empty() || mesh.indices.is_empty() {
8357            log::warn!("vector mesh has no geometry");
8358            return None;
8359        }
8360        if !mesh.indices.len().is_multiple_of(3) {
8361            log::warn!("vector mesh index count is not a triangle list");
8362            return None;
8363        }
8364        let vertex_count = u32::try_from(mesh.vertices.len()).ok()?;
8365        let index_count = u32::try_from(mesh.indices.len()).ok()?;
8366        if mesh.indices.iter().any(|&index| index >= vertex_count) {
8367            log::warn!("vector mesh index is outside its vertex buffer");
8368            return None;
8369        }
8370        if mesh.vertices.iter().any(|vertex| {
8371            vertex.pos.iter().any(|value| !value.is_finite())
8372                || vertex.color.iter().any(|value| !value.is_finite())
8373                || vertex.uv.iter().any(|value| !value.is_finite())
8374        }) {
8375            log::warn!("vector mesh contains non-finite vertex data");
8376            return None;
8377        }
8378        let verts: Vec<MeshVertex> = mesh
8379            .vertices
8380            .iter()
8381            .map(|v| MeshVertex {
8382                pos: v.pos,
8383                color: v.color,
8384                uv: v.uv,
8385            })
8386            .collect();
8387        let vbytes = bytemuck::cast_slice(&verts);
8388        let ibytes = bytemuck::cast_slice(&mesh.indices[..]);
8389        let bytes = u64::try_from(vbytes.len())
8390            .ok()?
8391            .checked_add(u64::try_from(ibytes.len()).ok()?)?;
8392        let max_buffer_size = self.device.limits().max_buffer_size;
8393        if vbytes.len() as u64 > max_buffer_size || ibytes.len() as u64 > max_buffer_size {
8394            return None;
8395        }
8396        let cacheable = bytes <= MAX_MESH_CACHE_BYTES
8397            && self.reserve_mesh_cache(bytes)
8398            && self.reserve_mesh_gpu_budget(bytes);
8399        let vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
8400            label: Some("vector mesh vertices"),
8401            size: vbytes.len().max(4) as u64,
8402            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
8403            mapped_at_creation: false,
8404        });
8405        let indices = self.device.create_buffer(&wgpu::BufferDescriptor {
8406            label: Some("vector mesh indices"),
8407            size: ibytes.len().max(4) as u64,
8408            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
8409            mapped_at_creation: false,
8410        });
8411        self.queue.write_buffer(&vertices, 0, vbytes);
8412        self.queue.write_buffer(&indices, 0, ibytes);
8413        let gpu = MeshGpu {
8414            vertices,
8415            indices,
8416            vertex_count,
8417            index_count,
8418            bytes,
8419        };
8420        if cacheable {
8421            let touch = self.next_cache_touch();
8422            self.mesh_cache.insert(
8423                key,
8424                MeshCacheEntry {
8425                    owner,
8426                    gpu: gpu.clone(),
8427                    last_used_frame: self.frame_index,
8428                    last_touch_frame: touch,
8429                },
8430            );
8431            self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_add(bytes);
8432        }
8433        Some(gpu)
8434    }
8435
8436    fn upload_mesh_geometry(&mut self, mesh: &Arc<repose_core::VectorMeshData>) -> Option<MeshGpu> {
8437        self.upload_mesh_geometry_with_owner(
8438            mesh,
8439            Self::mesh_cache_key(mesh),
8440            MeshOwner::Object(mesh.clone()),
8441        )
8442    }
8443
8444    fn upload_overlay_mesh(
8445        &mut self,
8446        meshes: &Arc<[repose_core::VectorMeshData]>,
8447        index: usize,
8448    ) -> Option<MeshGpu> {
8449        self.upload_mesh_geometry_with_owner(
8450            &meshes[index],
8451            Self::overlay_mesh_cache_key(meshes, index),
8452            MeshOwner::Overlay(meshes.clone()),
8453        )
8454    }
8455
8456    fn alloc_mesh_uniform(&mut self, u: MeshUniform) -> Option<u64> {
8457        let size = std::mem::size_of::<MeshUniform>() as u64;
8458        let next = self
8459            .mesh_uniform_head
8460            .checked_add(self.mesh_uniform_alignment)?;
8461        if next > self.mesh_uniform_cap || next > u64::from(u32::MAX) {
8462            log::warn!("mesh uniform buffer exhausted; remaining meshes skipped");
8463            return None;
8464        }
8465        let slot = self.mesh_uniform_head;
8466        if slot.checked_add(size)? > self.mesh_uniform_cap {
8467            return None;
8468        }
8469        self.queue
8470            .write_buffer(&self.mesh_uniform_buf, slot, bytemuck::bytes_of(&u));
8471        self.mesh_uniform_head = next;
8472        Some(slot)
8473    }
8474
8475    /// Write `affines` as one contiguous instance run. Returns the byte offset
8476    /// and record count for a `Cmd::VectorMesh`. Every placement in the run
8477    /// shares one paint uniform, which is the whole point: N copies of a rig
8478    /// frame cost one uniform slot and one draw.
8479    fn alloc_mesh_instances(&mut self, affines: &[[f32; 6]]) -> Option<(u64, u32)> {
8480        let count = u32::try_from(affines.len()).ok()?;
8481        if count == 0 {
8482            return None;
8483        }
8484        let stride = std::mem::size_of::<MeshInstance>() as u64;
8485        let bytes = u64::from(count) * stride;
8486        let slot = self.mesh_instance_head;
8487        let next = slot.checked_add(bytes)?;
8488        if next > self.mesh_instance_cap {
8489            log::warn!("mesh instance buffer exhausted; remaining meshes skipped");
8490            return None;
8491        }
8492        let mut staging = Vec::with_capacity(affines.len());
8493        for affine in affines {
8494            staging.push(MeshInstance::new(*affine));
8495        }
8496        self.queue.write_buffer(
8497            &self.mesh_instance_buf,
8498            slot,
8499            bytemuck::cast_slice(&staging),
8500        );
8501        self.mesh_instance_head = next;
8502        Some((slot, count))
8503    }
8504
8505    /// Shared isolation + backdrop composite for a source that needs the
8506    /// backdrop: snapshot the parent's region, render the source alone into a
8507    /// translator-owned graphics layer via `emit_source`, then composite that
8508    /// layer over the snapshot with the backdrop-blend shader. Works for
8509    /// surface parents and layer parents alike: the snapshot copy, isolation
8510    /// layer, and composite quad are all expressed in the parent target's
8511    /// pixel space. Callers must invoke this while the current pass targets
8512    /// the recorded parent: the translator never splits passes between here
8513    /// and the appended composite (only `BeginLayer`/perspective push new
8514    /// passes, and neither can intervene mid-call). The executor re-checks
8515    /// this (`BlendLayer.parent`) and skips a misplaced composite rather than
8516    /// drawing over the wrong target.
8517    ///
8518    /// `bounds` is the source's AABB in parent-target pixels; `emit_source`
8519    /// receives the isolation pass, the source-shifted transform, and the
8520    /// layer size in pixels.
8521    #[allow(clippy::too_many_arguments)]
8522    fn emit_isolated_blend_source(
8523        &mut self,
8524        bounds: repose_core::Rect,
8525        blend: repose_core::BlendMode,
8526        current_transform: &repose_core::Transform,
8527        current_pass: &mut Pass,
8528        active_clips: &[ActiveClip],
8529        passes: &mut Vec<Pass>,
8530        id_head: &mut u32,
8531        pass_id_head: &mut u64,
8532        ids_used: &mut Vec<u32>,
8533        scissor: (u32, u32, u32, u32),
8534        encoder: &mut wgpu::CommandEncoder,
8535        fb_w: f32,
8536        fb_h: f32,
8537        emit_source: impl FnOnce(
8538            &mut Self,
8539            &mut Pass,
8540            &repose_core::Transform,
8541            f32,
8542            f32,
8543            &mut wgpu::CommandEncoder,
8544        ),
8545    ) {
8546        if scissor.2 == 0 || scissor.3 == 0 || active_clips.iter().any(ActiveClip::blocked) {
8547            return;
8548        }
8549        let parent_target = current_pass.target;
8550        let (parent_w, parent_h) = match parent_target {
8551            PassTarget::Surface => (fb_w, fb_h),
8552            PassTarget::Layer(id) => match self.layer_pool.get(&id) {
8553                Some(layer) => (layer.width as f32, layer.height as f32),
8554                None => return,
8555            },
8556        };
8557        let local_rect = intersect(
8558            bounds,
8559            repose_core::Rect {
8560                x: 0.0,
8561                y: 0.0,
8562                w: parent_w,
8563                h: parent_h,
8564            },
8565        );
8566        if !local_rect.x.is_finite()
8567            || !local_rect.y.is_finite()
8568            || !local_rect.w.is_finite()
8569            || !local_rect.h.is_finite()
8570            || local_rect.w <= 0.0
8571            || local_rect.h <= 0.0
8572            || local_rect.w > self.device.limits().max_texture_dimension_2d as f32
8573            || local_rect.h > self.device.limits().max_texture_dimension_2d as f32
8574        {
8575            return;
8576        }
8577        let layer_rect = repose_core::Rect {
8578            x: local_rect.x.floor(),
8579            y: local_rect.y.floor(),
8580            w: (local_rect.x + local_rect.w).ceil() - local_rect.x.floor(),
8581            h: (local_rect.y + local_rect.h).ceil() - local_rect.y.floor(),
8582        };
8583        let w = layer_rect.w;
8584        let h = layer_rect.h;
8585        let local_rect = layer_rect;
8586        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8587            return;
8588        }
8589        let layer_id = *id_head;
8590        *id_head = id_head.wrapping_add(1);
8591        ids_used.push(layer_id);
8592
8593        // Snapshot the parent target region before compositing: the blend
8594        // shader samples it as the backdrop. The copy runs at execution
8595        // time, after all earlier passes have rendered.
8596        let snapshot_format = match parent_target {
8597            PassTarget::Surface if self.working_space => wgpu::TextureFormat::Rgba16Float,
8598            PassTarget::Surface => self.output_format,
8599            PassTarget::Layer(_) => self.layer_target_format(),
8600        };
8601        if !self.alloc_blend_snapshot(layer_id, w as u32, h as u32, snapshot_format) {
8602            log::warn!("blend snapshot budget exhausted; blend skipped");
8603            return;
8604        }
8605        let source_pass_id = *pass_id_head;
8606        *pass_id_head += 1;
8607        let resumed_pass_id = *pass_id_head;
8608        *pass_id_head += 1;
8609        self.blend_copies.push(BlendCopy {
8610            pass_id: resumed_pass_id,
8611            blend_id: layer_id,
8612            target: parent_target,
8613            region: layer_rect,
8614        });
8615
8616        // Render the mesh alone into the layer (layer-local shift so the
8617        // layer owns exactly the mesh bbox), then resume the parent pass.
8618        // The composite runs in the resumed parent pass.
8619        let mut layer_pass = Pass {
8620            id: source_pass_id,
8621            target: PassTarget::Layer(layer_id),
8622            initial_scissor: (0, 0, w as u32, h as u32),
8623            active_scissor: Some((0, 0, w as u32, h as u32)),
8624            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8625            cmds: Vec::new(),
8626        };
8627        self.replay_active_clips(
8628            active_clips,
8629            current_transform,
8630            (local_rect.x, local_rect.y),
8631            (w, h),
8632            &mut layer_pass,
8633            encoder,
8634        );
8635        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8636        let shift = repose_core::Transform::translate(-local_rect.x, -local_rect.y);
8637        let local = current_transform.combine(&shift);
8638        emit_source(self, &mut layer_pass, &local, w, h, encoder);
8639        let saved = std::mem::replace(
8640            current_pass,
8641            Pass {
8642                id: resumed_pass_id,
8643                target: parent_target,
8644                initial_scissor: scissor,
8645                active_scissor: Some(scissor),
8646                clear_color: None,
8647                cmds: Vec::new(),
8648            },
8649        );
8650        passes.push(layer_pass);
8651        passes.push(saved);
8652
8653        // Composite quad over the mesh bbox in the parent target's pixel
8654        // space. The executor maps this NDC against `parent`, so layer
8655        // parents at any origin work.
8656        let ndc = {
8657            let cx = local_rect.x + local_rect.w * 0.5;
8658            let cy = local_rect.y + local_rect.h * 0.5;
8659            let ndc_cx = (cx / parent_w) * 2.0 - 1.0;
8660            let ndc_cy = 1.0 - (cy / parent_h) * 2.0;
8661            let ndc_w = (local_rect.w / parent_w) * 2.0;
8662            let ndc_h = (local_rect.h / parent_h) * 2.0;
8663            [ndc_cx, ndc_cy, ndc_w, ndc_h]
8664        };
8665        let inst = BlendInstance {
8666            xywh: ndc,
8667            uv: [0.0, 0.0, 1.0, 1.0],
8668            color: [1.0, 1.0, 1.0, 1.0],
8669            fwd_mat: [1.0, 0.0, 0.0, 1.0],
8670            mode: blend.shader_mode(),
8671            _pad: [0.0; 3],
8672        };
8673        if self
8674            .blend_ring
8675            .grow_to_fit(
8676                &self.device,
8677                encoder,
8678                std::mem::size_of::<BlendInstance>() as u64,
8679            )
8680            .is_err()
8681        {
8682            return;
8683        }
8684        let bytes = bytemuck::bytes_of(&inst);
8685        let Ok(off) = self.blend_ring.alloc_write(&self.queue, bytes) else {
8686            return;
8687        };
8688        current_pass.cmds.push(Cmd::BlendLayer {
8689            off,
8690            cnt: 1,
8691            src_layer: layer_id,
8692            dst_layer: Some(layer_id),
8693            parent: parent_target,
8694            scissor,
8695        });
8696    }
8697
8698    /// [`Self::emit_isolated_blend_source`] for a `VectorMesh`: the isolation
8699    /// layer owns exactly the mesh bbox, shifted so the mesh lands at its
8700    /// world position inside the layer.
8701    #[allow(clippy::too_many_arguments)]
8702    fn emit_isolated_blend(
8703        &mut self,
8704        mesh: std::sync::Arc<repose_core::VectorMeshData>,
8705        transform: [f32; 6],
8706        paint: repose_core::PaintDesc,
8707        blend: repose_core::BlendMode,
8708        current_transform: &repose_core::Transform,
8709        current_pass: &mut Pass,
8710        active_clips: &[ActiveClip],
8711        passes: &mut Vec<Pass>,
8712        id_head: &mut u32,
8713        pass_id_head: &mut u64,
8714        ids_used: &mut Vec<u32>,
8715        scissor: (u32, u32, u32, u32),
8716        encoder: &mut wgpu::CommandEncoder,
8717        fb_w: f32,
8718        fb_h: f32,
8719    ) {
8720        let affine = combine_mesh_affine(current_transform, transform);
8721        let bounds = mesh_aabb(&mesh, affine);
8722        self.emit_isolated_blend_source(
8723            bounds,
8724            blend,
8725            current_transform,
8726            current_pass,
8727            active_clips,
8728            passes,
8729            id_head,
8730            pass_id_head,
8731            ids_used,
8732            scissor,
8733            encoder,
8734            fb_w,
8735            fb_h,
8736            |slf, pass, local, _w, _h, _encoder| {
8737                slf.emit_vector_mesh(
8738                    local,
8739                    &mesh,
8740                    transform,
8741                    &paint,
8742                    repose_core::BlendMode::Alpha,
8743                    &mut pass.cmds,
8744                );
8745            },
8746        );
8747    }
8748
8749    /// [`Self::emit_isolated_blend_source`] for an `ImageBlended` quad. The
8750    /// isolation layer is sized to the fitted draw rect and the image is
8751    /// sampled with its own UVs, so `fit`/`filter`/`source_rect` survive the
8752    /// round trip; the source layer composites with `Alpha` and the blend
8753    /// itself happens in the backdrop pass.
8754    #[allow(clippy::too_many_arguments)]
8755    fn emit_isolated_image(
8756        &mut self,
8757        draw_rect: repose_core::Rect,
8758        handle: u64,
8759        uv_rect: [f32; 4],
8760        tint: repose_core::Color,
8761        tile: bool,
8762        filter: ImageFilter,
8763        blend: repose_core::BlendMode,
8764        current_transform: &repose_core::Transform,
8765        current_pass: &mut Pass,
8766        active_clips: &[ActiveClip],
8767        passes: &mut Vec<Pass>,
8768        id_head: &mut u32,
8769        pass_id_head: &mut u64,
8770        ids_used: &mut Vec<u32>,
8771        scissor: (u32, u32, u32, u32),
8772        encoder: &mut wgpu::CommandEncoder,
8773        fb_w: f32,
8774        fb_h: f32,
8775    ) {
8776        let bounds = affine_aabb(current_transform, &draw_rect);
8777        self.emit_isolated_blend_source(
8778            bounds,
8779            blend,
8780            current_transform,
8781            current_pass,
8782            active_clips,
8783            passes,
8784            id_head,
8785            pass_id_head,
8786            ids_used,
8787            scissor,
8788            encoder,
8789            fb_w,
8790            fb_h,
8791            |slf, pass, local, w, h, encoder| {
8792                let (ndc_center, fwd_mat) = rect_to_instance_ndc(draw_rect, local, w, h);
8793                let inst = GlyphInstance {
8794                    xywh: ndc_center,
8795                    uv: uv_rect,
8796                    color: tint.to_linear(),
8797                    fwd_mat,
8798                };
8799                if let Some((off, _)) =
8800                    slf.glyph_color
8801                        .upload(&slf.device, &slf.queue, encoder, &[inst])
8802                {
8803                    pass.cmds.push(Cmd::ImageRgba {
8804                        off,
8805                        cnt: 1,
8806                        handle,
8807                        filter,
8808                        tile,
8809                        blend: repose_core::BlendMode::Alpha,
8810                    });
8811                }
8812            },
8813        );
8814    }
8815
8816    fn recycle_blend_snapshot(&mut self, snapshot: BlendSnapshot) {
8817        let key = snapshot.key;
8818        if let Some(old) = self.blend_snapshot_pool.insert(key, snapshot) {
8819            self.blend_snapshot_bytes_total =
8820                self.blend_snapshot_bytes_total.saturating_sub(old.bytes);
8821        }
8822        while self.blend_snapshot_bytes_total > MAX_BLEND_SNAPSHOT_BYTES
8823            || self.blend_snapshot_pool.len() > MAX_BLEND_SNAPSHOT_POOL_ENTRIES
8824        {
8825            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8826                break;
8827            };
8828            if let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) {
8829                self.blend_snapshot_bytes_total = self
8830                    .blend_snapshot_bytes_total
8831                    .saturating_sub(snapshot.bytes);
8832            }
8833        }
8834    }
8835
8836    fn recycle_blend_snapshots(&mut self) {
8837        for (_, snapshot) in std::mem::take(&mut self.blend_snapshots) {
8838            self.recycle_blend_snapshot(snapshot);
8839        }
8840    }
8841
8842    fn alloc_blend_snapshot(
8843        &mut self,
8844        layer_id: u32,
8845        w: u32,
8846        h: u32,
8847        format: wgpu::TextureFormat,
8848    ) -> bool {
8849        let key = BlendSnapshotKey {
8850            format,
8851            width: w,
8852            height: h,
8853        };
8854        if self
8855            .blend_snapshots
8856            .get(&layer_id)
8857            .is_some_and(|snapshot| snapshot.key == key)
8858        {
8859            return true;
8860        }
8861        if w == 0 || h == 0 {
8862            return false;
8863        }
8864        if let Some(old) = self.blend_snapshots.remove(&layer_id) {
8865            self.recycle_blend_snapshot(old);
8866        }
8867        if let Some(snapshot) = self.blend_snapshot_pool.remove(&key) {
8868            self.blend_snapshots.insert(layer_id, snapshot);
8869            return true;
8870        }
8871        let Ok(bytes) = texture_storage_bytes(format, w, h) else {
8872            return false;
8873        };
8874        if bytes > MAX_BLEND_SNAPSHOT_BYTES {
8875            return false;
8876        }
8877        while self.blend_snapshot_bytes_total.saturating_add(bytes) > MAX_BLEND_SNAPSHOT_BYTES
8878            || self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes
8879        {
8880            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8881                return false;
8882            };
8883            let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) else {
8884                return false;
8885            };
8886            self.blend_snapshot_bytes_total = self
8887                .blend_snapshot_bytes_total
8888                .saturating_sub(snapshot.bytes);
8889        }
8890        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
8891            label: Some("blend backdrop snapshot"),
8892            size: wgpu::Extent3d {
8893                width: w,
8894                height: h,
8895                depth_or_array_layers: 1,
8896            },
8897            mip_level_count: 1,
8898            sample_count: 1,
8899            dimension: wgpu::TextureDimension::D2,
8900            format,
8901            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
8902            view_formats: &[],
8903        });
8904        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
8905        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8906            label: Some("blend snapshot bind"),
8907            layout: &self.image_bind_layout_rgba,
8908            entries: &[
8909                wgpu::BindGroupEntry {
8910                    binding: 0,
8911                    resource: wgpu::BindingResource::TextureView(&view),
8912                },
8913                wgpu::BindGroupEntry {
8914                    binding: 1,
8915                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
8916                },
8917            ],
8918        });
8919        self.blend_snapshot_bytes_total = self.blend_snapshot_bytes_total.saturating_add(bytes);
8920        self.blend_snapshots.insert(
8921            layer_id,
8922            BlendSnapshot {
8923                key,
8924                texture: tex,
8925                bind,
8926                bytes,
8927            },
8928        );
8929        true
8930    }
8931
8932    fn remove_blend_snapshot(&mut self, layer_id: u32) {
8933        if let Some(snapshot) = self.blend_snapshots.remove(&layer_id) {
8934            self.recycle_blend_snapshot(snapshot);
8935        }
8936    }
8937
8938    fn blend_snapshot_texture(&self, layer_id: u32) -> Option<wgpu::Texture> {
8939        self.blend_snapshots
8940            .get(&layer_id)
8941            .map(|s| s.texture.clone())
8942    }
8943
8944    #[allow(clippy::too_many_arguments)]
8945    fn emit_vector_mesh(
8946        &mut self,
8947        current_transform: &Transform,
8948        mesh: &Arc<repose_core::VectorMeshData>,
8949        transform: [f32; 6],
8950        paint: &repose_core::PaintDesc,
8951        blend: repose_core::BlendMode,
8952        cmds: &mut Vec<Cmd>,
8953    ) {
8954        self.emit_vector_mesh_affines(current_transform, mesh, &[transform], paint, blend, cmds);
8955    }
8956
8957    /// One mesh, one paint, N placements, one draw. `transforms` are local
8958    /// affines, each combined with `current_transform` before upload, so a run
8959    /// of placements sharing a parent transform and a paint collapses into a
8960    /// single indexed draw with `icnt` instances.
8961    #[allow(clippy::too_many_arguments)]
8962    fn emit_vector_mesh_affines(
8963        &mut self,
8964        current_transform: &Transform,
8965        mesh: &Arc<repose_core::VectorMeshData>,
8966        transforms: &[[f32; 6]],
8967        paint: &repose_core::PaintDesc,
8968        blend: repose_core::BlendMode,
8969        cmds: &mut Vec<Cmd>,
8970    ) {
8971        let Some(mesh_gpu) = self.upload_mesh_geometry(mesh) else {
8972            return;
8973        };
8974        let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(paint)) else {
8975            return;
8976        };
8977        let affines: Vec<[f32; 6]> = transforms
8978            .iter()
8979            .map(|transform| combine_mesh_affine(current_transform, *transform))
8980            .collect();
8981        let Some((ioff, icnt)) = self.alloc_mesh_instances(&affines) else {
8982            return;
8983        };
8984        cmds.push(Cmd::VectorMesh {
8985            mesh: mesh_gpu,
8986            uoff,
8987            ioff,
8988            icnt,
8989            blend,
8990        });
8991    }
8992
8993    fn touch_callback_scope(&mut self, scope: CallbackScopeKey) {
8994        self.callback_scope_clock = self.callback_scope_clock.wrapping_add(1);
8995        let tick = self.callback_scope_clock;
8996        self.callback_scope_uses.insert(
8997            scope,
8998            CallbackScopeUse {
8999                tick,
9000                frame: self.frame_index,
9001            },
9002        );
9003    }
9004
9005    fn remove_callback_scope(&mut self, scope: &CallbackScopeKey) {
9006        self.callback_scoped_resources.remove(scope);
9007        self.callback_scope_uses.remove(scope);
9008        self.callback_scope_payloads.remove(scope);
9009    }
9010
9011    fn evict_callback_scope(&mut self, protected: &HashSet<CallbackScopeKey>) -> bool {
9012        let candidate = self
9013            .callback_scoped_resources
9014            .keys()
9015            .filter(|scope| !protected.contains(*scope))
9016            .min_by_key(|scope| {
9017                self.callback_scope_uses
9018                    .get(scope)
9019                    .map(|usage| usage.tick)
9020                    .unwrap_or(0)
9021            })
9022            .cloned();
9023        let Some(scope) = candidate else {
9024            return false;
9025        };
9026        self.remove_callback_scope(&scope);
9027        true
9028    }
9029
9030    fn prune_callback_scopes(&mut self) {
9031        let current_frame = self.frame_index;
9032        let mut dead: Vec<(CallbackScopeKey, u64)> = self
9033            .callback_scope_uses
9034            .iter()
9035            .filter_map(|(scope, usage)| {
9036                let payload_dead = self
9037                    .callback_scope_payloads
9038                    .get(scope)
9039                    .is_none_or(|weak| weak.upgrade().is_none());
9040                (payload_dead && !scope.callback.reusable).then(|| (scope.clone(), usage.tick))
9041            })
9042            .collect();
9043        dead.sort_by_key(|(_, tick)| *tick);
9044        for (scope, _) in dead {
9045            self.remove_callback_scope(&scope);
9046        }
9047
9048        if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
9049            return;
9050        }
9051        let mut inactive: Vec<(CallbackScopeKey, u64)> = self
9052            .callback_scope_uses
9053            .iter()
9054            .filter(|(_, usage)| usage.frame != current_frame)
9055            .map(|(scope, usage)| (scope.clone(), usage.tick))
9056            .collect();
9057        inactive.sort_by_key(|(_, tick)| *tick);
9058        for (scope, _) in inactive {
9059            if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
9060                break;
9061            }
9062            self.remove_callback_scope(&scope);
9063        }
9064    }
9065
9066    pub fn begin_frame(&mut self) -> anyhow::Result<()> {
9067        if self.frame_active {
9068            self.last_render_error = Some("renderer frame is already active".into());
9069            anyhow::bail!("renderer frame is already active");
9070        }
9071        self.last_render_error = None;
9072        self.frame_active = true;
9073        self.frame_index = self.frame_index.wrapping_add(1);
9074        self.apply_atlas_pending();
9075        self.slug_cache.next_frame();
9076        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
9077            composite.begin_frame();
9078        }
9079        for resources in self.callback_scoped_resources.values_mut() {
9080            if let Some(composite) = resources.get_mut::<DepthComposite>() {
9081                composite.begin_frame();
9082            }
9083        }
9084        Ok(())
9085    }
9086
9087    pub fn end_frame(&mut self) {
9088        if !self.frame_active {
9089            return;
9090        }
9091        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
9092            composite.end_frame();
9093        }
9094        for resources in self.callback_scoped_resources.values_mut() {
9095            if let Some(composite) = resources.get_mut::<DepthComposite>() {
9096                composite.end_frame();
9097            }
9098        }
9099        self.prune_callback_scopes();
9100        self.evict_unused_images();
9101        self.frame_active = false;
9102    }
9103
9104    pub fn last_render_error(&self) -> Option<&str> {
9105        self.last_render_error.as_deref()
9106    }
9107
9108    pub fn render_scene_to_encoder(
9109        &mut self,
9110        scene: &Scene,
9111        encoder: &mut wgpu::CommandEncoder,
9112        target_view: &wgpu::TextureView,
9113        clear_color_override: Option<[f64; 4]>,
9114    ) {
9115        self.render_scene_to_encoder_with_texture(
9116            scene,
9117            encoder,
9118            target_view,
9119            None,
9120            clear_color_override,
9121        )
9122    }
9123
9124    /// Same as [`render_scene_to_encoder`](Self::render_scene_to_encoder),
9125    /// plus the render target texture for isolated-blend backdrop snapshots.
9126    /// Pass `None` when the texture is unavailable (swapchain views); then
9127    /// surface-targeted isolated blends are skipped (the isolated source
9128    /// layer composites nowhere, so the mesh disappears).
9129    pub fn render_scene_to_encoder_with_texture(
9130        &mut self,
9131        scene: &Scene,
9132        encoder: &mut wgpu::CommandEncoder,
9133        target_view: &wgpu::TextureView,
9134        target_texture: Option<&wgpu::Texture>,
9135        clear_color_override: Option<[f64; 4]>,
9136    ) {
9137        if self.output_width == 0 || self.output_height == 0 {
9138            return;
9139        }
9140        self.sync_text_caches();
9141        fn visible(aabb: repose_core::Rect, clip: repose_core::Rect) -> bool {
9142            if !aabb.x.is_finite()
9143                || !aabb.y.is_finite()
9144                || !aabb.w.is_finite()
9145                || !aabb.h.is_finite()
9146                || aabb.w <= 0.0
9147                || aabb.h <= 0.0
9148            {
9149                return false;
9150            }
9151            let aabb = repose_core::Rect {
9152                x: aabb.x - 1.0,
9153                y: aabb.y - 1.0,
9154                w: aabb.w + 2.0,
9155                h: aabb.h + 2.0,
9156            };
9157            let clipped = intersect(aabb, clip);
9158            clipped.x.is_finite()
9159                && clipped.y.is_finite()
9160                && clipped.w.is_finite()
9161                && clipped.h.is_finite()
9162                && clipped.w > 0.0
9163                && clipped.h > 0.0
9164        }
9165
9166        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
9167            let x0 = (x / fb_w) * 2.0 - 1.0;
9168            let y0 = 1.0 - (y / fb_h) * 2.0;
9169            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
9170            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
9171            let min_x = x0.min(x1);
9172            let min_y = y0.min(y1);
9173            let w_ndc = (x1 - x0).abs();
9174            let h_ndc = (y1 - y0).abs();
9175            [min_x, min_y, w_ndc, h_ndc]
9176        }
9177
9178        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
9179            if fb_w == 0
9180                || fb_h == 0
9181                || !r.x.is_finite()
9182                || !r.y.is_finite()
9183                || !r.w.is_finite()
9184                || !r.h.is_finite()
9185                || r.w <= 0.0
9186                || r.h <= 0.0
9187            {
9188                return (0, 0, 0, 0);
9189            }
9190            // Inward: a clip must not admit any pixel outside the rect it
9191            // came from. Rounding out let clipped content bleed a pixel past
9192            // every clip edge, which shows up as a 1px line hugging the edge
9193            // of a letterboxed or clipped view.
9194            let x0 = r.x.ceil().max(0.0).min(fb_w as f32);
9195            let y0 = r.y.ceil().max(0.0).min(fb_h as f32);
9196            let x1 = (r.x + r.w).floor().max(x0).min(fb_w as f32);
9197            let y1 = (r.y + r.h).floor().max(y0).min(fb_h as f32);
9198            if x1 <= x0 || y1 <= y0 {
9199                return (0, 0, 0, 0);
9200            }
9201            (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
9202        }
9203
9204        let fb_w = self.output_width as f32;
9205        let fb_h = self.output_height as f32;
9206
9207        let mut passes: Vec<Pass> = Vec::with_capacity(1);
9208        let clear_color = clear_color_override
9209            .map(|color| {
9210                let alpha = color[3];
9211                [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha]
9212            })
9213            .unwrap_or_else(|| {
9214                let color = scene.clear_color.to_linear();
9215                let alpha = color[3] as f64;
9216                [
9217                    color[0] as f64 * alpha,
9218                    color[1] as f64 * alpha,
9219                    color[2] as f64 * alpha,
9220                    alpha,
9221                ]
9222            });
9223        let mut next_pass_id = 0u64;
9224        let mut current_pass: Pass = Pass {
9225            id: next_pass_id,
9226            target: PassTarget::Surface,
9227            initial_scissor: (0, 0, self.output_width, self.output_height),
9228            active_scissor: Some((0, 0, self.output_width, self.output_height)),
9229            clear_color: Some([
9230                clear_color[0] as f32,
9231                clear_color[1] as f32,
9232                clear_color[2] as f32,
9233                clear_color[3] as f32,
9234            ]),
9235            cmds: Vec::with_capacity(scene.nodes.len()),
9236        };
9237        next_pass_id += 1;
9238        let mut target_stack: Vec<PassTarget> = Vec::new();
9239        let mut layer_stack: Vec<LayerState> = Vec::new();
9240        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
9241
9242        struct Batch {
9243            rects: Vec<RectInstance>,
9244            borders: Vec<BorderInstance>,
9245            ellipses: Vec<EllipseInstance>,
9246            e_borders: Vec<EllipseBorderInstance>,
9247            arcs: Vec<ArcInstance>,
9248            masks: Vec<GlyphInstance>,
9249            colors: Vec<GlyphInstance>,
9250        }
9251
9252        impl Batch {
9253            fn new() -> Self {
9254                Self {
9255                    rects: vec![],
9256                    borders: vec![],
9257                    ellipses: vec![],
9258                    e_borders: vec![],
9259                    arcs: vec![],
9260                    masks: vec![],
9261                    colors: vec![],
9262                }
9263            }
9264
9265            fn is_empty(&self) -> bool {
9266                self.rects.is_empty()
9267                    && self.borders.is_empty()
9268                    && self.ellipses.is_empty()
9269                    && self.e_borders.is_empty()
9270                    && self.arcs.is_empty()
9271                    && self.masks.is_empty()
9272                    && self.colors.is_empty()
9273            }
9274
9275            fn flush(
9276                &mut self,
9277                pipes: (
9278                    &mut InstancedPipe<RectInstance>,
9279                    &mut InstancedPipe<BorderInstance>,
9280                    &mut InstancedPipe<EllipseInstance>,
9281                    &mut InstancedPipe<EllipseBorderInstance>,
9282                    &mut InstancedPipe<ArcInstance>,
9283                ),
9284                glyph_pipes: (
9285                    &mut InstancedPipe<GlyphInstance>,
9286                    &mut InstancedPipe<GlyphInstance>,
9287                ),
9288                device: &wgpu::Device,
9289                queue: &wgpu::Queue,
9290                encoder: &mut wgpu::CommandEncoder,
9291                cmds: &mut Vec<Cmd>,
9292            ) {
9293                let (rects, borders, ellipses, e_borders, arcs) = pipes;
9294                let (masks, colors) = glyph_pipes;
9295
9296                macro_rules! flush_one {
9297                    ($buf:ident, $pipe:expr, $variant:ident) => {
9298                        if !self.$buf.is_empty() {
9299                            if let Some((off, cnt)) =
9300                                $pipe.upload(device, queue, encoder, &self.$buf)
9301                            {
9302                                cmds.push(Cmd::$variant { off, cnt });
9303                            }
9304                            self.$buf.clear();
9305                        }
9306                    };
9307                }
9308
9309                flush_one!(rects, rects, Rect);
9310                flush_one!(borders, borders, Border);
9311                flush_one!(ellipses, ellipses, Ellipse);
9312                flush_one!(e_borders, e_borders, EllipseBorder);
9313                flush_one!(arcs, arcs, Arc);
9314                flush_one!(masks, masks, GlyphsMask);
9315                flush_one!(colors, colors, GlyphsColor);
9316            }
9317        }
9318
9319        self.rects.reset();
9320        self.borders.reset();
9321        self.ellipses.reset();
9322        self.ellipse_borders.reset();
9323        self.arcs.reset();
9324        self.glyph_mask.reset();
9325        self.glyph_color.reset();
9326        self.clip_ring.reset();
9327        self.blur_ring.reset();
9328        self.nv12.reset();
9329
9330        self.slug_ring.reset();
9331        self.mesh_uniform_head = 0;
9332        self.mesh_instance_head = 0;
9333        self.projective_ring.reset();
9334        self.blend_ring.reset();
9335        for id in self.flatten_layer_ids.drain(..) {
9336            if let Some(layer) = self.layer_pool.remove(&id) {
9337                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
9338                if layer.transient {
9339                    self.transient_layer_bytes_total =
9340                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
9341                }
9342            }
9343        }
9344        self.producer_layer_ids.clear();
9345        self.recycle_blend_snapshots();
9346        self.blend_copies.clear();
9347        let mut batch = Batch::new();
9348        // The blend mode is part of the run key: instances only batch together when
9349        // they resolve to the same pipeline.
9350        let mut image_run: Option<(
9351            u64,
9352            ImageFilter,
9353            bool,
9354            repose_core::BlendMode,
9355            Vec<GlyphInstance>,
9356        )> = None;
9357        let mut nv12_run: Option<(u64, ImageFilter, repose_core::BlendMode, Vec<Nv12Instance>)> =
9358            None;
9359        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
9360        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
9361        let mut flatten_stack: Vec<FlattenRecord> = Vec::new();
9362        let mut flatten_id_head: u32 = FLATTEN_ID_BASE;
9363        let mut flatten_ids_used: Vec<u32> = Vec::new();
9364        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
9365        let mut active_clips: Vec<ActiveClip> = Vec::with_capacity(8);
9366        let mut root_clip_rect = repose_core::Rect {
9367            x: 0.0,
9368            y: 0.0,
9369            w: fb_w,
9370            h: fb_h,
9371        };
9372
9373        let mut current_prim: Option<&'static str> = None;
9374
9375        macro_rules! flush_if_prim_changed {
9376            ($prim:literal, $pipe:expr) => {
9377                if current_prim != Some($prim) {
9378                    flush_batch!();
9379                    current_prim = Some($prim);
9380                }
9381            };
9382        }
9383
9384        macro_rules! flush_image_runs {
9385            () => {{
9386                if let Some((handle, filter, tile, blend, instances)) = image_run.take()
9387                    && let Some((off, cnt)) =
9388                        self.glyph_color
9389                            .upload(&self.device, &self.queue, encoder, &instances)
9390                {
9391                    current_pass.cmds.push(Cmd::ImageRgba {
9392                        off,
9393                        cnt,
9394                        handle,
9395                        filter,
9396                        tile,
9397                        blend,
9398                    });
9399                }
9400                if let Some((handle, filter, blend, instances)) = nv12_run.take()
9401                    && let Some((off, cnt)) =
9402                        self.nv12
9403                            .upload(&self.device, &self.queue, encoder, &instances)
9404                {
9405                    current_pass.cmds.push(Cmd::ImageNv12 {
9406                        off,
9407                        cnt,
9408                        handle,
9409                        filter,
9410                        blend,
9411                    });
9412                }
9413            }};
9414        }
9415
9416        macro_rules! flush_primitives {
9417            () => {{
9418                if !batch.is_empty() {
9419                    batch.flush(
9420                        (
9421                            &mut self.rects,
9422                            &mut self.borders,
9423                            &mut self.ellipses,
9424                            &mut self.ellipse_borders,
9425                            &mut self.arcs,
9426                        ),
9427                        (&mut self.glyph_mask, &mut self.glyph_color),
9428                        &self.device,
9429                        &self.queue,
9430                        encoder,
9431                        &mut current_pass.cmds,
9432                    )
9433                }
9434            }};
9435        }
9436
9437        macro_rules! flush_batch {
9438            () => {{
9439                flush_image_runs!();
9440                flush_primitives!();
9441            }};
9442        }
9443        let mut pending_layer_composite: Option<Cmd> = None;
9444        let shadow_layer_ids: HashSet<u32> = scene
9445            .nodes
9446            .iter()
9447            .filter_map(|node| match node {
9448                SceneNode::CompositeShadow { layer_id, .. } => Some(*layer_id),
9449                _ => None,
9450            })
9451            .collect();
9452        macro_rules! flush_pending_layer {
9453            () => {
9454                if let Some(command) = pending_layer_composite.take() {
9455                    current_pass.cmds.push(command);
9456                }
9457            };
9458        }
9459        for node in &scene.nodes {
9460            let t_identity = Transform::identity();
9461            let current_transform = transform_stack.last().unwrap_or(&t_identity);
9462
9463            // `Image` and `ImageBlended` differ only in the blend mode, and
9464            // or-patterns cannot bind a field one variant lacks, so lift both
9465            // out of the match and share the whole image path below.
9466            let image_spec = match node {
9467                SceneNode::Image {
9468                    rect,
9469                    handle,
9470                    tint,
9471                    style,
9472                } => Some((*rect, *handle, *tint, *style, repose_core::BlendMode::Alpha)),
9473                SceneNode::ImageBlended {
9474                    rect,
9475                    handle,
9476                    tint,
9477                    style,
9478                    blend,
9479                } => Some((*rect, *handle, *tint, *style, *blend)),
9480                _ => None,
9481            };
9482            if let Some((rect, handle, tint, style, blend)) = image_spec {
9483                let ImagePaintStyle {
9484                    fit,
9485                    filter,
9486                    source_rect,
9487                    alignment,
9488                } = style;
9489                let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9490                if !visible(affine_aabb(current_transform, &rect), clip) {
9491                    continue;
9492                }
9493                flush_batch!();
9494                let (img_w, img_h, is_nv12) = match self.resolve_image_for_draw(handle) {
9495                    Some(wh) => wh,
9496                    None => {
9497                        log::warn!("Image handle {} not found", handle);
9498                        continue;
9499                    }
9500                };
9501                let Some(source) = resolve_image_source(source_rect, img_w, img_h) else {
9502                    log::warn!(
9503                        "Image handle {} has invalid source rect {:?}",
9504                        handle,
9505                        source_rect
9506                    );
9507                    continue;
9508                };
9509                let Some((draw_rect, uv_rect)) = image_fit_geometry(rect, fit, alignment, &source)
9510                else {
9511                    continue;
9512                };
9513                let tile = matches!(fit, ImageFit::Tile);
9514                // Mirror the mesh path: backdrop-dependent modes isolate,
9515                // and so do the fixed-function ones when the source is
9516                // translucent (their blend states assume opaque source).
9517                let translucent_fixed_blend = matches!(
9518                    blend,
9519                    repose_core::BlendMode::Add
9520                        | repose_core::BlendMode::Multiply
9521                        | repose_core::BlendMode::Screen
9522                        | repose_core::BlendMode::Darken
9523                        | repose_core::BlendMode::Lighten
9524                ) && tint.3 < 255;
9525                if blend.needs_isolation() || translucent_fixed_blend {
9526                    if is_nv12 {
9527                        // NV12 sampling is only wired into the image
9528                        // pipeline; isolating would need an RGBA layer.
9529                        log::warn!("blend on an NV12 image is not supported");
9530                    } else {
9531                        let blend_scissor = to_scissor(
9532                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
9533                            current_target_size.0 as u32,
9534                            current_target_size.1 as u32,
9535                        );
9536                        self.emit_isolated_image(
9537                            draw_rect,
9538                            handle,
9539                            uv_rect,
9540                            tint,
9541                            tile,
9542                            filter,
9543                            blend,
9544                            current_transform,
9545                            &mut current_pass,
9546                            &active_clips,
9547                            &mut passes,
9548                            &mut flatten_id_head,
9549                            &mut next_pass_id,
9550                            &mut flatten_ids_used,
9551                            blend_scissor,
9552                            encoder,
9553                            fb_w,
9554                            fb_h,
9555                        );
9556                    }
9557                    continue;
9558                }
9559                let (ndc_center, fwd_mat) = rect_to_instance_ndc(
9560                    draw_rect,
9561                    current_transform,
9562                    current_target_size.0,
9563                    current_target_size.1,
9564                );
9565                if is_nv12 {
9566                    if image_run.is_some() {
9567                        flush_image_runs!();
9568                    }
9569                    let (uv_x_offset, uv_y_offset) =
9570                        if let Some(ImageTex::Nv12 {
9571                            w, h, color_info, ..
9572                        }) = self.images.get(&handle)
9573                        {
9574                            let chroma_w = w.div_ceil(2).max(1) as f32;
9575                            let chroma_h = h.div_ceil(2).max(1) as f32;
9576                            match color_info.chroma_siting {
9577                                ChromaSiting::Center => (0.0, 0.0),
9578                                ChromaSiting::Left => (-0.5 / chroma_w, 0.0),
9579                                ChromaSiting::TopLeft => (-0.5 / chroma_w, -0.5 / chroma_h),
9580                            }
9581                        } else {
9582                            (0.0, 0.0)
9583                        };
9584                    let inst = Nv12Instance {
9585                        xywh: ndc_center,
9586                        uv: uv_rect,
9587                        color: tint.to_linear(),
9588                        uv_x_offset,
9589                        uv_y_offset,
9590                        fwd_mat,
9591                    };
9592                    if nv12_run
9593                        .as_ref()
9594                        .is_some_and(|(run_handle, run_filter, run_blend, _)| {
9595                            *run_handle != handle || *run_filter != filter || *run_blend != blend
9596                        })
9597                    {
9598                        flush_image_runs!();
9599                    }
9600                    match &mut nv12_run {
9601                        Some((run_handle, run_filter, run_blend, instances))
9602                            if *run_handle == handle
9603                                && *run_filter == filter
9604                                && *run_blend == blend =>
9605                        {
9606                            instances.push(inst)
9607                        }
9608                        _ => nv12_run = Some((handle, filter, blend, vec![inst])),
9609                    }
9610                } else {
9611                    if nv12_run.is_some() {
9612                        flush_image_runs!();
9613                    }
9614                    let inst = GlyphInstance {
9615                        xywh: ndc_center,
9616                        uv: uv_rect,
9617                        color: tint.to_linear(),
9618                        fwd_mat,
9619                    };
9620                    if image_run.as_ref().is_some_and(
9621                        |(run_handle, run_filter, run_tile, run_blend, _)| {
9622                            *run_handle != handle
9623                                || *run_filter != filter
9624                                || *run_tile != tile
9625                                || *run_blend != blend
9626                        },
9627                    ) {
9628                        flush_image_runs!();
9629                    }
9630                    match &mut image_run {
9631                        Some((run_handle, run_filter, run_tile, run_blend, instances))
9632                            if *run_handle == handle
9633                                && *run_filter == filter
9634                                && *run_tile == tile
9635                                && *run_blend == blend =>
9636                        {
9637                            instances.push(inst)
9638                        }
9639                        _ => image_run = Some((handle, filter, tile, blend, vec![inst])),
9640                    }
9641                }
9642                continue;
9643            }
9644
9645            match node {
9646                SceneNode::Rect {
9647                    rect,
9648                    brush,
9649                    radius,
9650                } => {
9651                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9652                    if !visible(affine_aabb(current_transform, rect), clip) {
9653                        continue;
9654                    }
9655                    flush_if_prim_changed!("rect", &self.rects);
9656                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9657                        *rect,
9658                        current_transform,
9659                        current_target_size.0,
9660                        current_target_size.1,
9661                    );
9662                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9663                        brush_to_shape_fields(brush, rect, current_transform);
9664                    batch.rects.push(RectInstance {
9665                        xywh: ndc,
9666                        radii: radius.map(|r| r.0),
9667                        color0,
9668                        color1,
9669                        fwd_mat,
9670                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9671                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9672                        _pad: [0.0; 3],
9673                    });
9674                }
9675                SceneNode::Border {
9676                    rect,
9677                    brush,
9678                    width,
9679                    radius,
9680                } => {
9681                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9682                    if !visible(affine_aabb(current_transform, rect), clip) {
9683                        continue;
9684                    }
9685                    flush_if_prim_changed!("border", &self.borders);
9686                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9687                        *rect,
9688                        current_transform,
9689                        current_target_size.0,
9690                        current_target_size.1,
9691                    );
9692                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9693                        brush_to_shape_fields(brush, rect, current_transform);
9694                    batch.borders.push(BorderInstance {
9695                        xywh: ndc,
9696                        radii: radius.map(|r| r.0),
9697                        color0,
9698                        color1,
9699                        fwd_mat,
9700                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9701                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9702                        stroke: width.0,
9703                        _pad: [0.0; 2],
9704                    });
9705                }
9706                SceneNode::Ellipse { rect, brush } => {
9707                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9708                    if !visible(affine_aabb(current_transform, rect), clip) {
9709                        continue;
9710                    }
9711                    flush_if_prim_changed!("ellipse", &self.ellipses);
9712                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9713                        *rect,
9714                        current_transform,
9715                        current_target_size.0,
9716                        current_target_size.1,
9717                    );
9718                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9719                        brush_to_shape_fields(brush, rect, current_transform);
9720                    batch.ellipses.push(EllipseInstance {
9721                        xywh: ndc,
9722                        color0,
9723                        color1,
9724                        fwd_mat,
9725                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9726                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9727                        _pad: [0.0; 1],
9728                    });
9729                }
9730                SceneNode::EllipseBorder { rect, brush, width } => {
9731                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9732                    let cull_pad = width.0.abs() * 0.5 + 2.0;
9733                    let cull_rect = repose_core::Rect {
9734                        x: rect.x - cull_pad,
9735                        y: rect.y - cull_pad,
9736                        w: rect.w + cull_pad * 2.0,
9737                        h: rect.h + cull_pad * 2.0,
9738                    };
9739                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9740                        continue;
9741                    }
9742                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
9743                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9744                        *rect,
9745                        current_transform,
9746                        current_target_size.0,
9747                        current_target_size.1,
9748                    );
9749                    let pad_px = width.0 * 0.5 + 2.0;
9750                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9751                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9752                        brush_to_shape_fields(brush, rect, current_transform);
9753                    batch.e_borders.push(EllipseBorderInstance {
9754                        xywh: ndc,
9755                        color0,
9756                        color1,
9757                        fwd_mat,
9758                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9759                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9760                        stroke: width.0,
9761                        pad,
9762                        _pad: 0.0,
9763                    });
9764                }
9765                SceneNode::Arc {
9766                    rect,
9767                    start_angle,
9768                    sweep_angle,
9769                    stroke_width,
9770                    brush,
9771                    cap,
9772                } => {
9773                    if !start_angle.is_finite()
9774                        || !sweep_angle.is_finite()
9775                        || !stroke_width.0.is_finite()
9776                        || !rect.x.is_finite()
9777                        || !rect.y.is_finite()
9778                        || !rect.w.is_finite()
9779                        || !rect.h.is_finite()
9780                        || rect.w <= 0.0
9781                        || rect.h <= 0.0
9782                        || stroke_width.0 <= 0.0
9783                        || sweep_angle.abs() <= 1e-6
9784                    {
9785                        continue;
9786                    }
9787                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9788                    let cull_pad = stroke_width.0.abs() * 0.5 + 2.0;
9789                    let cull_rect = repose_core::Rect {
9790                        x: rect.x - cull_pad,
9791                        y: rect.y - cull_pad,
9792                        w: rect.w + cull_pad * 2.0,
9793                        h: rect.h + cull_pad * 2.0,
9794                    };
9795                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9796                        continue;
9797                    }
9798                    flush_if_prim_changed!("arc", &self.arcs);
9799                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9800                        *rect,
9801                        current_transform,
9802                        current_target_size.0.max(1.0),
9803                        current_target_size.1.max(1.0),
9804                    );
9805                    let pad_px = stroke_width.0 * 0.5 + 2.0;
9806                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9807                    let cap_val = match cap {
9808                        StrokeCap::Butt => 0.0,
9809                        StrokeCap::Round => 1.0,
9810                        StrokeCap::Square => 2.0,
9811                    };
9812                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9813                        brush_to_shape_fields(brush, rect, current_transform);
9814                    let (start, sweep) = if *sweep_angle < 0.0 {
9815                        (*start_angle + *sweep_angle, -*sweep_angle)
9816                    } else {
9817                        (*start_angle, *sweep_angle)
9818                    };
9819                    batch.arcs.push(ArcInstance {
9820                        xywh: ndc,
9821                        color0,
9822                        color1,
9823                        fwd_mat,
9824                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9825                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, cap_val as u32),
9826                        start_angle: start,
9827                        sweep_angle: sweep,
9828                        stroke: stroke_width.0,
9829                        pad,
9830                        _pad: 0.0,
9831                    });
9832                }
9833                SceneNode::Text {
9834                    rect,
9835                    text,
9836                    color,
9837                    size,
9838                    style,
9839                } => {
9840                    let TextPaintStyle {
9841                        font_family,
9842                        font_weight,
9843                        font_style,
9844                        text_decoration,
9845                        letter_spacing,
9846                        font_variation_settings,
9847                        baseline_shift,
9848                        draw_style,
9849                        ..
9850                    } = style;
9851                    let px = size.0;
9852                    if !px.is_finite()
9853                        || px <= 0.0
9854                        || !rect.x.is_finite()
9855                        || !rect.y.is_finite()
9856                        || !rect.w.is_finite()
9857                        || !rect.h.is_finite()
9858                    {
9859                        continue;
9860                    }
9861                    let margin = px * 4.0 + letter_spacing.0.abs() * 2.0;
9862                    let text_bounds = repose_core::Rect {
9863                        x: rect.x - margin,
9864                        y: rect.y - margin,
9865                        w: rect.w + margin * 2.0,
9866                        h: rect.h + margin * 2.0,
9867                    };
9868                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9869                    if !visible(affine_aabb(current_transform, &text_bounds), clip) {
9870                        continue;
9871                    }
9872                    flush_batch!(); // flush any prior primitives
9873
9874                    let lh_ratio = rect.h / px;
9875                    let fw = font_weight.0;
9876                    let fs = if *font_style == FontStyle::Italic {
9877                        1
9878                    } else {
9879                        0
9880                    };
9881                    let shaped = self.shape_text_cached(
9882                        text,
9883                        px,
9884                        lh_ratio,
9885                        *font_family,
9886                        fw,
9887                        fs,
9888                        letter_spacing.0,
9889                        font_variation_settings.as_ref(),
9890                    );
9891                    let baseline_y = shaped.glyphs.first().map(|g| rect.y + g.y);
9892
9893                    let fwd = forward_rs_mat(current_transform);
9894                    let has_linear = fwd != [1.0, 0.0, 0.0, 1.0];
9895
9896                    let lin = current_transform.linear();
9897                    let tr_x = current_transform.translate_x;
9898                    let tr_y = current_transform.translate_y;
9899
9900                    let make_glyph_instance =
9901                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 4]) {
9902                            if has_linear {
9903                                let gc_x = gx + gw * 0.5;
9904                                let gc_y = gy + gh * 0.5;
9905                                let wc_x = lin[0] * gc_x + lin[1] * gc_y + tr_x;
9906                                let wc_y = lin[2] * gc_x + lin[3] * gc_y + tr_y;
9907                                let ww = gw * current_transform.scale_x;
9908                                let wh = gh * current_transform.scale_y;
9909                                let ex = fwd[0].abs() * ww * 0.5 + fwd[1].abs() * wh * 0.5;
9910                                let ey = fwd[2].abs() * ww * 0.5 + fwd[3].abs() * wh * 0.5;
9911                                let ndc_tl = to_ndc(
9912                                    wc_x - ex,
9913                                    wc_y - ey,
9914                                    ex * 2.0,
9915                                    ey * 2.0,
9916                                    current_target_size.0,
9917                                    current_target_size.1,
9918                                );
9919                                let ndc = [
9920                                    ndc_tl[0] + ndc_tl[2] * 0.5,
9921                                    ndc_tl[1] + ndc_tl[3] * 0.5,
9922                                    ndc_tl[2],
9923                                    ndc_tl[3],
9924                                ];
9925                                (ndc, fwd)
9926                            } else {
9927                                let (sx, sy) = if current_transform.scale_x == 1.0
9928                                    && current_transform.scale_y == 1.0
9929                                {
9930                                    (gx.round(), gy.round())
9931                                } else {
9932                                    (gx, gy)
9933                                };
9934                                rect_to_instance_ndc(
9935                                    repose_core::Rect {
9936                                        x: sx,
9937                                        y: sy,
9938                                        w: gw,
9939                                        h: gh,
9940                                    },
9941                                    current_transform,
9942                                    current_target_size.0,
9943                                    current_target_size.1,
9944                                )
9945                            }
9946                        };
9947
9948                    let baseline_shift_y: f32 = px * baseline_shift.0;
9949
9950                    let (
9951                        draws_fill,
9952                        is_stroke,
9953                        stroke_width,
9954                        stroke_cap,
9955                        stroke_join,
9956                        stroke_miter,
9957                        stroke_path_effect,
9958                        stroke_color,
9959                    ) = match draw_style {
9960                        repose_core::DrawStyle::Stroke {
9961                            width,
9962                            cap,
9963                            join,
9964                            miter,
9965                            path_effect,
9966                        } => (
9967                            false,
9968                            true,
9969                            *width,
9970                            *cap,
9971                            *join,
9972                            *miter,
9973                            path_effect.clone(),
9974                            None,
9975                        ),
9976                        repose_core::DrawStyle::FillAndStroke {
9977                            width,
9978                            cap,
9979                            join,
9980                            miter,
9981                            path_effect,
9982                            outline_color,
9983                        } => (
9984                            true,
9985                            true,
9986                            *width,
9987                            *cap,
9988                            *join,
9989                            *miter,
9990                            path_effect.clone(),
9991                            // The outline is part of this text's appearance,
9992                            // so it fades with the text alpha like the fill.
9993                            // Without this a faded label keeps a solid halo.
9994                            outline_color.map(|c| {
9995                                c.with_alpha_f32(
9996                                    f32::from(c.3) * f32::from(color.3) / (255.0 * 255.0),
9997                                )
9998                            }),
9999                        ),
10000                        _ => (
10001                            true,
10002                            false,
10003                            0.0,
10004                            repose_core::StrokeCap::Butt,
10005                            repose_core::StrokeJoin::Miter,
10006                            4.0,
10007                            None,
10008                            None,
10009                        ),
10010                    };
10011                    // A contrasting outline reads as a halo, so it goes under
10012                    // the fill; a same-colour stroke is faux-bold and stays on
10013                    // top.
10014                    let outline_under = stroke_color.is_some();
10015                    let stroke_tess_key = if is_stroke {
10016                        Some(slug::StrokeTessKey::new(
10017                            stroke_width,
10018                            stroke_cap,
10019                            stroke_join,
10020                            stroke_miter,
10021                            &stroke_path_effect,
10022                        ))
10023                    } else {
10024                        None
10025                    };
10026
10027                    let mut legacy_shaped = None;
10028                    for (glyph_index, sg) in shaped.glyphs.iter().enumerate() {
10029                        if self.slug_enabled {
10030                            let ck = &sg.cache_key;
10031                            let color_linear = color.to_linear();
10032                            let stroke_color_linear = stroke_color.unwrap_or(*color).to_linear();
10033                            let glyph_pos = (rect.x + sg.x, rect.y + sg.y + baseline_shift_y);
10034                            let fill_key = draws_fill.then(|| {
10035                                Self::slug_draw_key(
10036                                    ck,
10037                                    true,
10038                                    None,
10039                                    current_transform,
10040                                    glyph_pos,
10041                                    px,
10042                                    current_target_size,
10043                                    color_linear,
10044                                )
10045                            });
10046                            let stroke_key = is_stroke.then(|| {
10047                                Self::slug_draw_key(
10048                                    ck,
10049                                    false,
10050                                    stroke_tess_key.as_ref(),
10051                                    current_transform,
10052                                    glyph_pos,
10053                                    px,
10054                                    current_target_size,
10055                                    stroke_color_linear,
10056                                )
10057                            });
10058
10059                            // The draw cache outlives the frame, so probing both keys up
10060                            // front lets a steady-state glyph clone neither buffer.
10061                            let fill_cache = fill_key
10062                                .as_ref()
10063                                .and_then(|k| self.get_cached_slug_vertices(k));
10064                            let stroke_cache = stroke_key
10065                                .as_ref()
10066                                .and_then(|k| self.get_cached_slug_vertices(k));
10067                            let fill_miss = fill_key.is_some() && fill_cache.is_none();
10068                            let stroke_miss = stroke_key.is_some() && stroke_cache.is_none();
10069                            if (fill_key.is_some() || stroke_key.is_some())
10070                                && !fill_miss
10071                                && !stroke_miss
10072                            {
10073                                let mut push = |cached: &Option<Arc<[slug::TessVertex]>>| {
10074                                    if let Some(vertices) = cached {
10075                                        slug_verts_local.extend_from_slice(vertices);
10076                                    }
10077                                };
10078                                if outline_under {
10079                                    push(&stroke_cache);
10080                                    push(&fill_cache);
10081                                } else {
10082                                    push(&fill_cache);
10083                                    push(&stroke_cache);
10084                                }
10085                                continue;
10086                            }
10087
10088                            let outline = self.cached_glyph_outline(ck);
10089                            if let Some(commands) = outline.as_ref() {
10090                                let font_size_px = f32::from_bits(ck.font_size_bits);
10091                                if draws_fill {
10092                                    self.slug_cache.get_or_insert(
10093                                        ck.clone(),
10094                                        font_size_px,
10095                                        commands.as_ref(),
10096                                    );
10097                                }
10098                                if is_stroke {
10099                                    self.slug_cache.get_or_insert_stroke(
10100                                        ck.clone(),
10101                                        font_size_px,
10102                                        commands.as_ref(),
10103                                        stroke_width,
10104                                        stroke_cap,
10105                                        stroke_join,
10106                                        stroke_miter,
10107                                        &stroke_path_effect,
10108                                    );
10109                                }
10110                            }
10111
10112                            if let Some(entry) = self.slug_cache.get(ck) {
10113                                let ox = rect.x + sg.x;
10114                                let oy = rect.y + sg.y + baseline_shift_y;
10115                                let tf = |x: f32, y: f32| -> (f32, f32) {
10116                                    if has_linear {
10117                                        (
10118                                            lin[0] * x + lin[1] * y + current_transform.translate_x,
10119                                            lin[2] * x + lin[3] * y + current_transform.translate_y,
10120                                        )
10121                                    } else {
10122                                        (
10123                                            x * current_transform.scale_x
10124                                                + current_transform.translate_x,
10125                                            y * current_transform.scale_y
10126                                                + current_transform.translate_y,
10127                                        )
10128                                    }
10129                                };
10130                                let tw = current_target_size.0;
10131                                let th = current_target_size.1;
10132                                let fill_vertices = if draws_fill && fill_cache.is_none() {
10133                                    entry.fill_vertices.clone().unwrap_or_default()
10134                                } else {
10135                                    Vec::new()
10136                                };
10137                                let stroke_vertices = if stroke_cache.is_none() {
10138                                    stroke_tess_key
10139                                        .as_ref()
10140                                        .and_then(|key| entry.stroke_variants.get(key))
10141                                        .map(|variant| variant.vertices.clone())
10142                                        .unwrap_or_default()
10143                                } else {
10144                                    Vec::new()
10145                                };
10146                                let mut emit =
10147                                    |verts: &Vec<[f32; 2]>,
10148                                     key: &Option<SlugDrawKey>,
10149                                     cached: &Option<Arc<[slug::TessVertex]>>,
10150                                     vertex_color: [f32; 4]| {
10151                                        if let Some(vertices) = cached {
10152                                            slug_verts_local.extend_from_slice(vertices);
10153                                            return;
10154                                        }
10155                                        let Some(key) = key.clone() else {
10156                                            return;
10157                                        };
10158                                        let transformed: Vec<slug::TessVertex> = verts
10159                                            .iter()
10160                                            .map(|v| {
10161                                                let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
10162                                                slug::TessVertex {
10163                                                    ndc_pos: [
10164                                                        sx / tw * 2.0 - 1.0,
10165                                                        -(sy / th) * 2.0 + 1.0,
10166                                                    ],
10167                                                    color: vertex_color,
10168                                                }
10169                                            })
10170                                            .collect();
10171                                        let vertices = self.cached_slug_vertices(key, transformed);
10172                                        slug_verts_local.extend_from_slice(&vertices);
10173                                    };
10174                                // A contrasting outline reads as a halo and
10175                                // goes under the fill; a same-colour stroke is
10176                                // faux-bold and stays on top.
10177                                if outline_under {
10178                                    emit(
10179                                        &stroke_vertices,
10180                                        &stroke_key,
10181                                        &stroke_cache,
10182                                        stroke_color_linear,
10183                                    );
10184                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
10185                                } else {
10186                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
10187                                    emit(
10188                                        &stroke_vertices,
10189                                        &stroke_key,
10190                                        &stroke_cache,
10191                                        stroke_color_linear,
10192                                    );
10193                                }
10194                                continue;
10195                            }
10196                        }
10197
10198                        if !draws_fill {
10199                            continue;
10200                        }
10201                        if legacy_shaped.is_none() {
10202                            legacy_shaped = Some(self.legacy_shape_text_cached(
10203                                text,
10204                                px,
10205                                lh_ratio,
10206                                *font_family,
10207                                fw,
10208                                fs,
10209                                letter_spacing.0,
10210                                font_variation_settings.as_ref(),
10211                            ));
10212                        }
10213                        let Some(legacy_glyph) = legacy_shaped
10214                            .as_ref()
10215                            .and_then(|glyphs| glyphs.get(glyph_index))
10216                        else {
10217                            continue;
10218                        };
10219                        let gx = rect.x + legacy_glyph.x + legacy_glyph.bearing_x;
10220                        let gy =
10221                            rect.y + legacy_glyph.y - legacy_glyph.bearing_y + baseline_shift_y;
10222                        if let Some(info) =
10223                            self.upload_glyph_color(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10224                        {
10225                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10226                            batch.colors.push(GlyphInstance {
10227                                xywh: ndc,
10228                                uv: [info.u0, info.v1, info.u1, info.v0],
10229                                color: color.to_linear(),
10230                                fwd_mat,
10231                            });
10232                        } else if let Some(info) =
10233                            self.upload_glyph_mask(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10234                        {
10235                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10236                            batch.masks.push(GlyphInstance {
10237                                xywh: ndc,
10238                                uv: [info.u0, info.v1, info.u1, info.v0],
10239                                color: color.to_linear(),
10240                                fwd_mat,
10241                            });
10242                        }
10243                    }
10244
10245                    // Upload slug vertices if any
10246                    if !slug_verts_local.is_empty() {
10247                        let bytes = bytemuck::cast_slice(&slug_verts_local);
10248                        let byte_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
10249                        let Ok(count) = u32::try_from(slug_verts_local.len()) else {
10250                            slug_verts_local.clear();
10251                            continue;
10252                        };
10253                        if self
10254                            .slug_ring
10255                            .grow_to_fit(&self.device, encoder, byte_len)
10256                            .is_ok()
10257                            && let Ok(off) = self.slug_ring.alloc_write(&self.queue, bytes)
10258                        {
10259                            current_pass
10260                                .cmds
10261                                .push(Cmd::GlyphsVector { off, cnt: count });
10262                        }
10263                        slug_verts_local.clear();
10264                    }
10265
10266                    // Text decoration: underline / strikethrough
10267                    if (text_decoration.underline || text_decoration.strikethrough)
10268                        && let Some(baseline_y) = baseline_y
10269                    {
10270                        flush_batch!();
10271                        current_prim = Some("rect");
10272                        let deco_color = text_decoration.color.unwrap_or(*color);
10273                        let thickness = (px * 0.07).max(1.0);
10274
10275                        if text_decoration.underline {
10276                            let dy = baseline_y + px * 0.1;
10277                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10278                                repose_core::Rect {
10279                                    x: rect.x,
10280                                    y: dy,
10281                                    w: rect.w,
10282                                    h: thickness,
10283                                },
10284                                current_transform,
10285                                current_target_size.0,
10286                                current_target_size.1,
10287                            );
10288                            batch.rects.push(RectInstance {
10289                                xywh: ndc,
10290                                radii: [0.0; 4],
10291                                color0: deco_color.to_linear(),
10292                                color1: [0.0; 4],
10293                                fwd_mat,
10294                                grad: [0.0; 4],
10295                                bits: 0,
10296                                _pad: [0.0; 3],
10297                            });
10298                        }
10299                        if text_decoration.strikethrough {
10300                            let sy = baseline_y - px * 0.3;
10301                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10302                                repose_core::Rect {
10303                                    x: rect.x,
10304                                    y: sy,
10305                                    w: rect.w,
10306                                    h: thickness,
10307                                },
10308                                current_transform,
10309                                current_target_size.0,
10310                                current_target_size.1,
10311                            );
10312                            batch.rects.push(RectInstance {
10313                                xywh: ndc,
10314                                radii: [0.0; 4],
10315                                color0: deco_color.to_linear(),
10316                                color1: [0.0; 4],
10317                                fwd_mat,
10318                                grad: [0.0; 4],
10319                                bits: 0,
10320                                _pad: [0.0; 3],
10321                            });
10322                        }
10323                    }
10324                }
10325                SceneNode::Coverage {
10326                    rect,
10327                    handle,
10328                    color,
10329                } => {
10330                    flush_batch!();
10331                    // Unknown handles are skipped (same policy as images);
10332                    // the lookup also marks the tile used for eviction.
10333                    let Some((tile_w, tile_h)) = self.coverage_dimensions(*handle) else {
10334                        log::warn!("Coverage handle {handle} not found");
10335                        continue;
10336                    };
10337                    // The tile composites at its registered size; `rect`
10338                    // positions its top-left.
10339                    let draw_rect = repose_core::Rect {
10340                        x: rect.x,
10341                        y: rect.y,
10342                        w: tile_w as f32,
10343                        h: tile_h as f32,
10344                    };
10345                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10346                    if !visible(affine_aabb(current_transform, &draw_rect), clip) {
10347                        continue;
10348                    }
10349                    let (ndc_center, fwd_mat) = rect_to_instance_ndc(
10350                        draw_rect,
10351                        current_transform,
10352                        current_target_size.0,
10353                        current_target_size.1,
10354                    );
10355                    let inst = GlyphInstance {
10356                        xywh: ndc_center,
10357                        uv: [0.0, 1.0, 1.0, 0.0],
10358                        color: color.to_linear(),
10359                        fwd_mat,
10360                    };
10361                    if let Some((off, _)) =
10362                        self.glyph_color
10363                            .upload(&self.device, &self.queue, encoder, &[inst])
10364                    {
10365                        current_pass.cmds.push(Cmd::Coverage {
10366                            off,
10367                            cnt: 1,
10368                            handle: *handle,
10369                        });
10370                    }
10371                }
10372                SceneNode::PushClip { rect, radius, op } => {
10373                    flush_batch!();
10374
10375                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
10376                    let t_identity = Transform::identity();
10377                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10378                    let transformed = affine_aabb(current_transform, rect);
10379                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10380                    let next_scissor = if is_diff {
10381                        top
10382                    } else {
10383                        intersect(top, transformed)
10384                    };
10385                    scissor_stack.push(next_scissor);
10386                    let scissor = to_scissor(
10387                        &next_scissor,
10388                        current_target_size.0 as u32,
10389                        current_target_size.1 as u32,
10390                    );
10391                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10392                    let mut blocked =
10393                        current_pass.active_scissor.is_none() || (has_inverse && !is_diff);
10394                    if has_inverse && is_diff {
10395                        log::error!(
10396                            "unsupported clip nesting: Difference cannot contain a Difference clip"
10397                        );
10398                        blocked = true;
10399                    }
10400                    let has_area = transformed.x.is_finite()
10401                        && transformed.y.is_finite()
10402                        && transformed.w.is_finite()
10403                        && transformed.h.is_finite()
10404                        && transformed.w > 0.0
10405                        && transformed.h > 0.0;
10406                    let mut applied = false;
10407                    let mut off = 0;
10408                    if !blocked && has_area && (is_diff || scissor.2 > 0 && scissor.3 > 0) {
10409                        let clip_ndc_tl = to_ndc(
10410                            transformed.x,
10411                            transformed.y,
10412                            transformed.w,
10413                            transformed.h,
10414                            current_target_size.0,
10415                            current_target_size.1,
10416                        );
10417                        let inst = ClipInstance {
10418                            xywh: [
10419                                clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
10420                                clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
10421                                clip_ndc_tl[2],
10422                                clip_ndc_tl[3],
10423                            ],
10424                            radii: radius.map(|r| r.0),
10425                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10426                        };
10427                        let bytes = bytemuck::bytes_of(&inst);
10428                        if self
10429                            .clip_ring
10430                            .grow_to_fit(
10431                                &self.device,
10432                                encoder,
10433                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
10434                            )
10435                            .is_ok()
10436                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
10437                        {
10438                            off = new_off;
10439                            applied = true;
10440                            current_pass.cmds.push(Cmd::ClipPush {
10441                                off,
10442                                cnt: 1,
10443                                scissor,
10444                                difference: is_diff,
10445                                applied,
10446                            });
10447                        } else {
10448                            blocked = !is_diff;
10449                        }
10450                    } else if !is_diff {
10451                        blocked = true;
10452                    }
10453                    current_pass.active_scissor = if blocked {
10454                        None
10455                    } else {
10456                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10457                    };
10458                    active_clips.push(ActiveClip::Rect {
10459                        off,
10460                        cnt: u32::from(applied),
10461                        rect: transformed,
10462                        radii: radius.map(|r| r.0),
10463                        scissor,
10464                        difference: is_diff,
10465                        applied,
10466                        blocked,
10467                    });
10468                }
10469                SceneNode::PopClip => {
10470                    flush_batch!();
10471
10472                    if scissor_stack.is_empty() || active_clips.is_empty() {
10473                        log::error!("PopClip does not match an active clip");
10474                    } else {
10475                        scissor_stack.pop();
10476                    }
10477                    let clip = active_clips.pop();
10478                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10479                    let scissor = to_scissor(
10480                        &top,
10481                        current_target_size.0 as u32,
10482                        current_target_size.1 as u32,
10483                    );
10484                    current_pass.active_scissor =
10485                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10486                    match clip {
10487                        Some(ActiveClip::Rect {
10488                            off,
10489                            cnt,
10490                            scissor: stencil_scissor,
10491                            difference,
10492                            applied,
10493                            ..
10494                        }) if applied && cnt > 0 => {
10495                            current_pass.cmds.push(Cmd::ClipPop {
10496                                off,
10497                                cnt,
10498                                scissor,
10499                                stencil_scissor,
10500                                difference,
10501                                applied: true,
10502                            });
10503                        }
10504                        Some(ActiveClip::Vector { .. }) => {
10505                            log::error!("PopClip matched a vector clip");
10506                        }
10507                        None => {}
10508                        _ => {}
10509                    }
10510                }
10511                SceneNode::Shadow {
10512                    rect,
10513                    radius,
10514                    elevation: _,
10515                    color,
10516                } => {
10517                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10518                    if !visible(affine_aabb(current_transform, rect), clip) {
10519                        continue;
10520                    }
10521                    flush_if_prim_changed!("rect", &self.rects);
10522                    let (ndc, fwd_mat) = rect_to_instance_ndc(
10523                        *rect,
10524                        current_transform,
10525                        current_target_size.0,
10526                        current_target_size.1,
10527                    );
10528                    let (_brush_type, color0, _color1, _grad_p0, _grad_p1) =
10529                        brush_to_instance_fields(&Brush::Solid(*color));
10530                    batch.rects.push(RectInstance {
10531                        xywh: ndc,
10532                        radii: radius.map(|r| r.0),
10533                        color0,
10534                        color1: [0.0; 4],
10535                        fwd_mat,
10536                        grad: [0.0; 4],
10537                        bits: 0,
10538                        _pad: [0.0; 3],
10539                    });
10540                }
10541                SceneNode::PushTransform { transform } => {
10542                    flush_batch!(); // flush before transform change
10543                    if transform.has_perspective() {
10544                        // True perspective cannot ride the affine fast path:
10545                        // flatten the subtree into an offscreen layer and
10546                        // composite it back projectively (CSS-style). See
10547                        // `push_perspective_layer`.
10548                        let top = *transform_stack.last().unwrap_or(&t_identity);
10549                        self.push_perspective_layer(
10550                            *transform,
10551                            top,
10552                            &mut transform_stack,
10553                            &mut scissor_stack,
10554                            &mut root_clip_rect,
10555                            &mut current_target_size,
10556                            &mut current_pass,
10557                            &mut active_clips,
10558                            &mut passes,
10559                            &mut target_stack,
10560                            &mut flatten_stack,
10561                            &mut flatten_id_head,
10562                            &mut next_pass_id,
10563                            &mut flatten_ids_used,
10564                            encoder,
10565                        );
10566                    } else {
10567                        let combined = current_transform.combine(transform);
10568                        transform_stack.push(combined);
10569                    }
10570                }
10571                SceneNode::PopTransform => {
10572                    flush_batch!(); // flush before transform change
10573                    if let Some(rec) = flatten_stack.last() {
10574                        // A flatten level closes when the stack is back to the
10575                        // two entries this flatten pushed (stripped transform +
10576                        // layer-local shift); deeper plain pushes close first.
10577                        if transform_stack.len() == rec.stack_len + 2 {
10578                            let rec = flatten_stack.pop().expect("checked above");
10579                            transform_stack.pop();
10580                            transform_stack.pop();
10581                            self.pop_perspective_layer(
10582                                rec,
10583                                &mut scissor_stack,
10584                                &mut root_clip_rect,
10585                                &mut current_target_size,
10586                                &mut current_pass,
10587                                &mut active_clips,
10588                                &mut passes,
10589                                &mut target_stack,
10590                                &mut next_pass_id,
10591                                encoder,
10592                            );
10593                            continue;
10594                        }
10595                    }
10596                    transform_stack.pop();
10597                }
10598                SceneNode::BeginLayer {
10599                    rect,
10600                    layer_id,
10601                    alpha,
10602                    blur_radius_x,
10603                    blur_radius_y,
10604                    rectangle_edge,
10605                } => {
10606                    flush_batch!();
10607                    let width_f = rect.w.round();
10608                    let height_f = rect.h.round();
10609                    let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
10610                    if !width_f.is_finite()
10611                        || !height_f.is_finite()
10612                        || width_f < 1.0
10613                        || height_f < 1.0
10614                        || width_f > max_dimension
10615                        || height_f > max_dimension
10616                    {
10617                        log::warn!("BeginLayer dimensions are invalid");
10618                        continue;
10619                    }
10620                    let width = width_f as u32;
10621                    let height = height_f as u32;
10622                    let parent_scissors =
10623                        std::mem::replace(&mut scissor_stack, Vec::with_capacity(8));
10624                    let parent_root_clip = std::mem::replace(
10625                        &mut root_clip_rect,
10626                        repose_core::Rect {
10627                            x: 0.0,
10628                            y: 0.0,
10629                            w: width as f32,
10630                            h: height as f32,
10631                        },
10632                    );
10633                    scissor_stack.push(root_clip_rect);
10634                    let layer_pass_id = next_pass_id;
10635                    next_pass_id += 1;
10636                    let parent_clips = std::mem::take(&mut active_clips);
10637                    let mut layer_pass = Pass {
10638                        id: layer_pass_id,
10639                        target: PassTarget::Layer(*layer_id),
10640                        initial_scissor: (0, 0, width, height),
10641                        active_scissor: Some((0, 0, width, height)),
10642                        clear_color: Some([0.0, 0.0, 0.0, 0.0]),
10643                        cmds: Vec::new(),
10644                    };
10645                    let parent_transform = *current_transform;
10646                    active_clips = self.replay_active_clips(
10647                        &parent_clips,
10648                        &parent_transform,
10649                        (rect.x, rect.y),
10650                        (width as f32, height as f32),
10651                        &mut layer_pass,
10652                        encoder,
10653                    );
10654                    let saved = std::mem::replace(&mut current_pass, layer_pass);
10655                    if let Some(top) = transform_stack.last_mut() {
10656                        *top = Transform::identity();
10657                    }
10658                    layer_stack.push(LayerState {
10659                        layer_id: *layer_id,
10660                        parent_target: saved.target,
10661                        parent_scissors,
10662                        parent_root_clip,
10663                        parent_size: current_target_size,
10664                        parent_transform,
10665                        parent_scissor: saved.active_scissor,
10666                        parent_clips,
10667                        alpha: *alpha,
10668                        blur: (blur_radius_x.0, blur_radius_y.0),
10669                        rectangle_edge: *rectangle_edge,
10670                    });
10671                    passes.push(saved);
10672                    if self.get_or_create_layer(*layer_id, width, height, *rect, false) {
10673                        self.producer_layer_ids.insert(*layer_id);
10674                    }
10675                    current_target_size = (width as f32, height as f32);
10676                }
10677                SceneNode::EndLayer { layer_id } => {
10678                    flush_batch!();
10679                    if layer_stack.last().map(|state| state.layer_id) != Some(*layer_id) {
10680                        log::warn!("EndLayer {} does not match active layer", layer_id);
10681                        continue;
10682                    }
10683                    let state = layer_stack.pop().expect("checked above");
10684                    scissor_stack = state.parent_scissors;
10685                    root_clip_rect = state.parent_root_clip;
10686                    active_clips = state.parent_clips;
10687                    if let Some(top) = transform_stack.last_mut() {
10688                        *top = state.parent_transform;
10689                    }
10690                    current_target_size = state.parent_size;
10691                    let resumed_pass_id = next_pass_id;
10692                    next_pass_id += 1;
10693                    let saved = std::mem::replace(
10694                        &mut current_pass,
10695                        Pass {
10696                            id: resumed_pass_id,
10697                            target: state.parent_target,
10698                            initial_scissor: state.parent_scissor.unwrap_or((0, 0, 1, 1)),
10699                            active_scissor: state.parent_scissor,
10700                            clear_color: None,
10701                            cmds: Vec::new(),
10702                        },
10703                    );
10704                    passes.push(saved);
10705                    let Some(layer) = self.layer_pool.get(layer_id).cloned() else {
10706                        continue;
10707                    };
10708                    let layer_rect = repose_core::Rect {
10709                        x: layer.rect_px.0,
10710                        y: layer.rect_px.1,
10711                        w: layer.rect_px.2,
10712                        h: layer.rect_px.3,
10713                    };
10714                    let local_layer_rect = affine_aabb(&state.parent_transform, &layer_rect);
10715                    let (parent_width, parent_height) = state.parent_size;
10716                    let parent_clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10717                    let composite_rect = if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10718                        let pad_x = blur_pad(state.blur.0);
10719                        let pad_y = blur_pad(state.blur.1);
10720                        repose_core::Rect {
10721                            x: local_layer_rect.x - pad_x,
10722                            y: local_layer_rect.y - pad_y,
10723                            w: local_layer_rect.w + pad_x * 2.0,
10724                            h: local_layer_rect.h + pad_y * 2.0,
10725                        }
10726                    } else {
10727                        local_layer_rect
10728                    };
10729                    let shadow_follows = shadow_layer_ids.contains(layer_id);
10730                    if !visible(composite_rect, parent_clip) {
10731                        continue;
10732                    }
10733                    let mut composite = None;
10734                    if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10735                        flush_batch!();
10736                        let pad_x = blur_pad(state.blur.0);
10737                        let pad_y = blur_pad(state.blur.1);
10738                        let rect = repose_core::Rect {
10739                            x: local_layer_rect.x - pad_x,
10740                            y: local_layer_rect.y - pad_y,
10741                            w: local_layer_rect.w + pad_x * 2.0,
10742                            h: local_layer_rect.h + pad_y * 2.0,
10743                        };
10744                        let ndc =
10745                            to_ndc(rect.x, rect.y, rect.w, rect.h, parent_width, parent_height);
10746                        let edge_mode = if state.rectangle_edge { 0 } else { 1 };
10747                        let sigma = [blur_sigma(state.blur.0), blur_sigma(state.blur.1)];
10748                        if let Some(scratch_id) = self.emit_separable_blur(
10749                            *layer_id,
10750                            BLUR_SLOT_CONTENT,
10751                            sigma,
10752                            (pad_x, pad_y),
10753                            edge_mode,
10754                            false,
10755                            [0.0; 4],
10756                            &mut passes,
10757                            &mut current_pass,
10758                            &mut next_pass_id,
10759                            encoder,
10760                        ) {
10761                            let inst = BlurInstance {
10762                                xywh: [
10763                                    ndc[0] + ndc[2] * 0.5,
10764                                    ndc[1] + ndc[3] * 0.5,
10765                                    ndc[2],
10766                                    ndc[3],
10767                                ],
10768                                uv: [0.0, 0.0, 1.0, 1.0],
10769                                color: [1.0, 1.0, 1.0, state.alpha],
10770                                sigma_px: sigma,
10771                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10772                                axis: 1,
10773                                edge_mode,
10774                                shape: [0.0; 4],
10775                            };
10776                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10777                                composite = Some(Cmd::CompositeBlur {
10778                                    off,
10779                                    cnt: 1,
10780                                    layer_id: scratch_id,
10781                                });
10782                            }
10783                        }
10784                    }
10785                    // No scratch (transient budget exhausted) leaves the layer
10786                    // sharp rather than dropped.
10787                    if composite.is_none() {
10788                        let ndc = to_ndc(
10789                            local_layer_rect.x,
10790                            local_layer_rect.y,
10791                            local_layer_rect.w,
10792                            local_layer_rect.h,
10793                            parent_width,
10794                            parent_height,
10795                        );
10796                        let inst = GlyphInstance {
10797                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
10798                            uv: [0.0, 1.0, 1.0, 0.0],
10799                            color: [1.0, 1.0, 1.0, state.alpha],
10800                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10801                        };
10802                        composite = self
10803                            .glyph_color
10804                            .upload(&self.device, &self.queue, encoder, &[inst])
10805                            .map(|(off, cnt)| Cmd::CompositeLayer {
10806                                off,
10807                                cnt,
10808                                layer_id: *layer_id,
10809                            });
10810                    }
10811                    if let Some(command) = composite {
10812                        if shadow_follows {
10813                            pending_layer_composite = Some(command);
10814                        } else {
10815                            current_pass.cmds.push(command);
10816                        }
10817                    }
10818                }
10819                SceneNode::CompositeShadow {
10820                    layer_id,
10821                    blur_px,
10822                    offset_px,
10823                    color,
10824                    shape_px,
10825                } => {
10826                    let sigma = blur_sigma(blur_px.0);
10827                    let pad = blur_pad(blur_px.0);
10828                    let layer = self.layer_pool.get(layer_id).cloned();
10829                    if let Some(layer) = layer {
10830                        let layer_rect = repose_core::Rect {
10831                            x: layer.rect_px.0,
10832                            y: layer.rect_px.1,
10833                            w: layer.rect_px.2,
10834                            h: layer.rect_px.3,
10835                        };
10836                        let local_layer_rect =
10837                            affine_aabb(transform_stack.last().unwrap_or(&t_identity), &layer_rect);
10838                        let sx = local_layer_rect.x + offset_px.0.0 - pad;
10839                        let sy = local_layer_rect.y + offset_px.1.0 - pad;
10840                        let sw = local_layer_rect.w + pad * 2.0;
10841                        let sh = local_layer_rect.h + pad * 2.0;
10842                        let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10843                        if !visible(
10844                            repose_core::Rect {
10845                                x: sx,
10846                                y: sy,
10847                                w: sw,
10848                                h: sh,
10849                            },
10850                            clip,
10851                        ) {
10852                            flush_pending_layer!();
10853                            continue;
10854                        }
10855                        flush_batch!();
10856                        let shadow_color = color.to_linear();
10857                        let max_r = 0.5 * layer.rect_px.2.min(layer.rect_px.3).max(0.0);
10858                        let shape = shape_px.map(|r| r.0.clamp(0.0, max_r));
10859                        // Decal edges: a drop shadow's falloff lives outside the
10860                        // source's own bounds, so out-of-range taps must
10861                        // contribute nothing rather than clamp to the edge texel.
10862                        let scratch_id = self.emit_separable_blur(
10863                            *layer_id,
10864                            BLUR_SLOT_SHADOW,
10865                            [sigma, sigma],
10866                            (pad, pad),
10867                            1,
10868                            true,
10869                            shape,
10870                            &mut passes,
10871                            &mut current_pass,
10872                            &mut next_pass_id,
10873                            encoder,
10874                        );
10875                        if let Some(scratch_id) = scratch_id {
10876                            let ndc_tl = to_ndc(
10877                                sx,
10878                                sy,
10879                                sw,
10880                                sh,
10881                                current_target_size.0,
10882                                current_target_size.1,
10883                            );
10884                            let inst = BlurInstance {
10885                                xywh: [
10886                                    ndc_tl[0] + ndc_tl[2] * 0.5,
10887                                    ndc_tl[1] + ndc_tl[3] * 0.5,
10888                                    ndc_tl[2],
10889                                    ndc_tl[3],
10890                                ],
10891                                uv: [0.0, 0.0, 1.0, 1.0],
10892                                color: shadow_color,
10893                                sigma_px: [sigma, sigma],
10894                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10895                                axis: 1,
10896                                edge_mode: 1,
10897                                shape: [0.0; 4],
10898                            };
10899                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10900                                current_pass.cmds.push(Cmd::CompositeShadow {
10901                                    off,
10902                                    cnt: 1,
10903                                    layer_id: scratch_id,
10904                                });
10905                            }
10906                        }
10907                    }
10908                    flush_pending_layer!();
10909                }
10910                SceneNode::VectorMesh {
10911                    mesh,
10912                    transform,
10913                    paint,
10914                    clip: _,
10915                    blend,
10916                } => {
10917                    let affine = combine_mesh_affine(current_transform, *transform);
10918                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10919                    if !visible(mesh_aabb(mesh, affine), clip) {
10920                        continue;
10921                    }
10922                    flush_batch!();
10923                    let needs_isolated = mesh_blend_needs_isolation(*blend, mesh, paint);
10924                    if needs_isolated {
10925                        let blend_scissor = to_scissor(
10926                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
10927                            current_target_size.0 as u32,
10928                            current_target_size.1 as u32,
10929                        );
10930                        self.emit_isolated_blend(
10931                            mesh.clone(),
10932                            *transform,
10933                            *paint,
10934                            *blend,
10935                            current_transform,
10936                            &mut current_pass,
10937                            &active_clips,
10938                            &mut passes,
10939                            &mut flatten_id_head,
10940                            &mut next_pass_id,
10941                            &mut flatten_ids_used,
10942                            blend_scissor,
10943                            encoder,
10944                            fb_w,
10945                            fb_h,
10946                        );
10947                    } else {
10948                        let t_identity = Transform::identity();
10949                        let current_transform = transform_stack.last().unwrap_or(&t_identity);
10950                        self.emit_vector_mesh(
10951                            current_transform,
10952                            mesh,
10953                            *transform,
10954                            paint,
10955                            *blend,
10956                            &mut current_pass.cmds,
10957                        );
10958                    }
10959                }
10960                SceneNode::VectorMeshInstanced {
10961                    mesh,
10962                    transforms,
10963                    paint,
10964                    blend,
10965                } => {
10966                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10967                    let t_identity = Transform::identity();
10968                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10969                    // Cull per placement: an off-screen instance contributes
10970                    // nothing and never reaches the instance buffer.
10971                    let live: Vec<[f32; 6]> = transforms
10972                        .iter()
10973                        .copied()
10974                        .filter(|transform| {
10975                            let affine = combine_mesh_affine(current_transform, *transform);
10976                            visible(mesh_aabb(mesh, affine), clip)
10977                        })
10978                        .collect();
10979                    if live.is_empty() {
10980                        continue;
10981                    }
10982                    flush_batch!();
10983                    if mesh_blend_needs_isolation(*blend, mesh, paint) {
10984                        // Each of these needs its own isolated source layer, so
10985                        // batching them would not save anything.
10986                        for transform in &live {
10987                            self.emit_vector_mesh(
10988                                current_transform,
10989                                mesh,
10990                                *transform,
10991                                paint,
10992                                *blend,
10993                                &mut current_pass.cmds,
10994                            );
10995                        }
10996                    } else {
10997                        self.emit_vector_mesh_affines(
10998                            current_transform,
10999                            mesh,
11000                            &live,
11001                            paint,
11002                            *blend,
11003                            &mut current_pass.cmds,
11004                        );
11005                    }
11006                }
11007                SceneNode::VectorOverlay { meshes } => {
11008                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11009                    let has_visible = meshes
11010                        .iter()
11011                        .any(|mesh| visible(mesh_aabb(mesh, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip));
11012                    if !has_visible {
11013                        continue;
11014                    }
11015                    flush_batch!();
11016                    for (index, m) in meshes.iter().enumerate() {
11017                        if !visible(mesh_aabb(m, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip) {
11018                            continue;
11019                        }
11020                        let Some(mesh_gpu) = self.upload_overlay_mesh(meshes, index) else {
11021                            continue;
11022                        };
11023                        let Some(uoff) = self.alloc_mesh_uniform(MeshUniform::identity()) else {
11024                            continue;
11025                        };
11026                        let Some((ioff, icnt)) = self.alloc_mesh_instances(&[IDENTITY_AFFINE])
11027                        else {
11028                            continue;
11029                        };
11030                        current_pass.cmds.push(Cmd::VectorOverlay {
11031                            mesh: mesh_gpu,
11032                            uoff,
11033                            ioff,
11034                            icnt,
11035                        });
11036                    }
11037                }
11038                SceneNode::PushVectorClip { mesh, op } => {
11039                    flush_batch!();
11040                    let difference = matches!(op, repose_core::ClipOp::Difference);
11041                    let t_identity = Transform::identity();
11042                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
11043                    let affine =
11044                        combine_mesh_affine(current_transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
11045                    let aabb = mesh_aabb(mesh, affine);
11046                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11047                    let next = if difference {
11048                        top
11049                    } else {
11050                        intersect(top, aabb)
11051                    };
11052                    scissor_stack.push(next);
11053                    let scissor = to_scissor(
11054                        &next,
11055                        current_target_size.0 as u32,
11056                        current_target_size.1 as u32,
11057                    );
11058                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
11059                    let mut blocked = current_pass.active_scissor.is_none() || has_inverse;
11060                    if has_inverse {
11061                        log::error!(
11062                            "unsupported clip nesting: a Difference clip cannot contain another vector clip"
11063                        );
11064                    }
11065                    let has_area = aabb.x.is_finite()
11066                        && aabb.y.is_finite()
11067                        && aabb.w.is_finite()
11068                        && aabb.h.is_finite()
11069                        && aabb.w > 0.0
11070                        && aabb.h > 0.0;
11071                    let mut applied = false;
11072                    let mut geometry = None;
11073                    let mut uoff = 0;
11074                    let mut ioff = 0;
11075                    let mut icnt = 0;
11076                    if !blocked
11077                        && has_area
11078                        && (if difference {
11079                            visible(aabb, top)
11080                        } else {
11081                            scissor.2 > 0 && scissor.3 > 0
11082                        })
11083                    {
11084                        if let Some(mesh_gpu) = self.upload_mesh_geometry(mesh)
11085                            && let Some(slot) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
11086                                &repose_core::PaintDesc::Solid,
11087                            ))
11088                            && let Some((run_off, run_cnt)) = self.alloc_mesh_instances(&[affine])
11089                        {
11090                            geometry = Some(mesh_gpu.clone());
11091                            uoff = slot;
11092                            ioff = run_off;
11093                            icnt = run_cnt;
11094                            applied = true;
11095                            current_pass.cmds.push(Cmd::VectorClipPush {
11096                                mesh: mesh_gpu,
11097                                uoff,
11098                                ioff,
11099                                icnt,
11100                                scissor,
11101                                difference,
11102                                applied,
11103                            });
11104                        } else {
11105                            blocked = !difference;
11106                        }
11107                    } else if !difference {
11108                        blocked = true;
11109                    }
11110                    current_pass.active_scissor = if blocked {
11111                        None
11112                    } else {
11113                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
11114                    };
11115                    active_clips.push(ActiveClip::Vector {
11116                        mesh: geometry,
11117                        source: mesh.clone(),
11118                        uoff,
11119                        ioff,
11120                        icnt,
11121                        affine,
11122                        scissor,
11123                        difference,
11124                        applied,
11125                        blocked,
11126                    });
11127                }
11128                SceneNode::PopVectorClip => {
11129                    flush_batch!();
11130                    if scissor_stack.is_empty() || active_clips.is_empty() {
11131                        log::error!("PopVectorClip does not match an active clip");
11132                    } else {
11133                        scissor_stack.pop();
11134                    }
11135                    let clip = active_clips.pop();
11136                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11137                    let scissor = to_scissor(
11138                        &top,
11139                        current_target_size.0 as u32,
11140                        current_target_size.1 as u32,
11141                    );
11142                    current_pass.active_scissor =
11143                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
11144                    match clip {
11145                        Some(ActiveClip::Vector {
11146                            mesh: Some(mesh),
11147                            uoff,
11148                            ioff,
11149                            icnt,
11150                            scissor: stencil_scissor,
11151                            difference,
11152                            applied,
11153                            ..
11154                        }) if applied && mesh.vertex_count > 0 && mesh.index_count > 0 => {
11155                            current_pass.cmds.push(Cmd::VectorClipPop {
11156                                mesh,
11157                                uoff,
11158                                ioff,
11159                                icnt,
11160                                scissor,
11161                                stencil_scissor,
11162                                difference,
11163                                applied: true,
11164                            });
11165                        }
11166                        Some(ActiveClip::Rect { .. }) => {
11167                            log::error!("PopVectorClip matched a rounded-rect clip");
11168                        }
11169                        None => {}
11170                        _ => {}
11171                    }
11172                }
11173                SceneNode::Callback { rect, payload } => {
11174                    let t = transform_stack
11175                        .last()
11176                        .copied()
11177                        .unwrap_or(Transform::identity());
11178                    let transformed = affine_aabb(&t, rect);
11179                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11180                    let clip_rect = intersect(transformed, top);
11181                    let scissor = to_scissor(
11182                        &clip_rect,
11183                        current_target_size.0 as u32,
11184                        current_target_size.1 as u32,
11185                    );
11186                    let restore_scissor = to_scissor(
11187                        &top,
11188                        current_target_size.0 as u32,
11189                        current_target_size.1 as u32,
11190                    );
11191                    if current_pass.active_scissor.is_none()
11192                        || !visible(transformed, top)
11193                        || scissor.2 == 0
11194                        || scissor.3 == 0
11195                    {
11196                        continue;
11197                    }
11198                    flush_batch!();
11199                    current_pass.cmds.push(Cmd::Callback {
11200                        rect: transformed,
11201                        clip_rect,
11202                        scissor,
11203                        restore_scissor,
11204                        callback_id: Arc::as_ptr(payload) as *const () as usize,
11205                        payload: payload.clone(),
11206                    });
11207                }
11208                _ => {}
11209            }
11210        }
11211
11212        flush_pending_layer!();
11213        flush_batch!();
11214        passes.push(current_pass);
11215
11216        {
11217            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
11218            let mut prepare_list: Vec<(usize, Arc<Callback>)> = Vec::new();
11219            for pass in &passes {
11220                for command in &pass.cmds {
11221                    if let Cmd::Callback { payload, .. } = command
11222                        && payload.downcast_ref::<Callback>().is_some()
11223                    {
11224                        let ptr = Arc::as_ptr(payload) as *const () as usize;
11225                        if seen.insert(ptr)
11226                            && let Ok(cb_arc) = payload.clone().downcast::<Callback>()
11227                        {
11228                            prepare_list.push((ptr, cb_arc));
11229                        }
11230                    }
11231                }
11232            }
11233            if !prepare_list.is_empty() {
11234                let mut callback_targets: HashMap<usize, Vec<(PassTarget, ScreenDescriptor)>> =
11235                    HashMap::new();
11236                for pass in &passes {
11237                    let (size, target_format, sample_count) = match pass.target {
11238                        PassTarget::Surface => (
11239                            [self.output_width, self.output_height],
11240                            if self.working_space {
11241                                wgpu::TextureFormat::Rgba16Float
11242                            } else {
11243                                self.output_format
11244                            },
11245                            self.active_surface_msaa_samples(),
11246                        ),
11247                        PassTarget::Layer(layer_id) => {
11248                            let layer = self.layer_pool.get(&layer_id);
11249                            (
11250                                layer.map_or([self.output_width, self.output_height], |layer| {
11251                                    [layer.width, layer.height]
11252                                }),
11253                                self.layer_target_format(),
11254                                1,
11255                            )
11256                        }
11257                    };
11258                    for command in &pass.cmds {
11259                        if let Cmd::Callback { payload, .. } = command {
11260                            let descriptor = ScreenDescriptor {
11261                                size_in_pixels: size,
11262                                pixels_per_point: self.pixels_per_point,
11263                                target_format,
11264                                sample_count,
11265                            };
11266                            let targets = callback_targets
11267                                .entry(Arc::as_ptr(payload) as *const () as usize)
11268                                .or_default();
11269                            if !targets.iter().any(|(target, target_desc)| {
11270                                *target == pass.target
11271                                    && target_desc.target_format == descriptor.target_format
11272                                    && target_desc.sample_count == descriptor.sample_count
11273                            }) {
11274                                targets.push((pass.target, descriptor));
11275                            }
11276                        }
11277                    }
11278                }
11279
11280                let default_descriptor = ScreenDescriptor {
11281                    size_in_pixels: [self.output_width, self.output_height],
11282                    pixels_per_point: self.pixels_per_point,
11283                    target_format: if self.working_space {
11284                        wgpu::TextureFormat::Rgba16Float
11285                    } else {
11286                        self.output_format
11287                    },
11288                    sample_count: self.active_surface_msaa_samples(),
11289                };
11290                let default_descriptors = [(PassTarget::Surface, default_descriptor)];
11291                let mut prepare_encoder =
11292                    self.device
11293                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11294                            label: Some("callback prepare"),
11295                        });
11296                let mut finish_encoder =
11297                    self.device
11298                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11299                            label: Some("callback finish prepare"),
11300                        });
11301                let mut active_callback_scopes = HashSet::new();
11302                for (key, cb) in &prepare_list {
11303                    let descriptors = callback_targets
11304                        .get(key)
11305                        .map(Vec::as_slice)
11306                        .unwrap_or(&default_descriptors);
11307                    for &(target, screen_desc) in descriptors {
11308                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11309                        active_callback_scopes.insert(scope.clone());
11310                        if !scope.callback.reusable
11311                            && self
11312                                .callback_scope_payloads
11313                                .get(&scope)
11314                                .is_some_and(|payload| payload.upgrade().is_none())
11315                        {
11316                            self.remove_callback_scope(&scope);
11317                        }
11318                        let payload: Weak<Callback> = Arc::downgrade(cb);
11319                        self.callback_scope_payloads.insert(scope, payload);
11320                    }
11321                }
11322                let mut prepare_buffers = Vec::new();
11323                let mut finish_buffers = Vec::new();
11324                for (key, cb) in &prepare_list {
11325                    let descriptors = callback_targets
11326                        .get(key)
11327                        .map(Vec::as_slice)
11328                        .unwrap_or(&default_descriptors);
11329                    for &(target, screen_desc) in descriptors {
11330                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11331                        self.touch_callback_scope(scope.clone());
11332                        if !self.callback_scoped_resources.contains_key(&scope)
11333                            && self.callback_scoped_resources.len() >= MAX_CALLBACK_SCOPES
11334                        {
11335                            self.evict_callback_scope(&active_callback_scopes);
11336                        }
11337                        let resources = self.callback_scoped_resources.entry(scope).or_default();
11338                        // The renderer publishes the device's downlevel
11339                        // capabilities on its base resources; seed every
11340                        // callback bucket with a clone so `prepare` sees
11341                        // them.
11342                        if !resources.contains::<wgpu::DownlevelCapabilities>()
11343                            && let Some(capabilities) =
11344                                self.callback_resources.get::<wgpu::DownlevelCapabilities>()
11345                        {
11346                            resources.insert(capabilities.clone());
11347                        }
11348                        prepare_buffers.extend(cb.0.prepare(
11349                            &self.device,
11350                            &self.queue,
11351                            &mut prepare_encoder,
11352                            &screen_desc,
11353                            resources,
11354                        ));
11355                    }
11356                }
11357                for (key, cb) in &prepare_list {
11358                    let descriptors = callback_targets
11359                        .get(key)
11360                        .map(Vec::as_slice)
11361                        .unwrap_or(&default_descriptors);
11362                    for &(target, screen_desc) in descriptors {
11363                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11364                        self.touch_callback_scope(scope.clone());
11365                        if let Some(resources) = self.callback_scoped_resources.get_mut(&scope) {
11366                            finish_buffers.extend(cb.0.finish_prepare(
11367                                &self.device,
11368                                &self.queue,
11369                                &mut finish_encoder,
11370                                &screen_desc,
11371                                resources,
11372                            ));
11373                        }
11374                    }
11375                }
11376                let mut callback_buffers =
11377                    Vec::with_capacity(2 + prepare_buffers.len() + finish_buffers.len());
11378                callback_buffers.push(prepare_encoder.finish());
11379                callback_buffers.extend(prepare_buffers);
11380                callback_buffers.push(finish_encoder.finish());
11381                callback_buffers.extend(finish_buffers);
11382                self.queue.submit(callback_buffers);
11383            }
11384        }
11385
11386        let globals_bytes = std::mem::size_of::<Globals>() as u64;
11387        if !self.ensure_globals_staging(passes.len()) {
11388            return;
11389        }
11390        for (i, pass) in passes.iter().enumerate() {
11391            let (target_w, target_h) = match pass.target {
11392                PassTarget::Surface => (fb_w, fb_h),
11393                PassTarget::Layer(layer_id) => {
11394                    let lt = self.layer_pool.get(&layer_id);
11395                    (
11396                        lt.map_or(fb_w, |l| l.width as f32),
11397                        lt.map_or(fb_h, |l| l.height as f32),
11398                    )
11399                }
11400            };
11401            self.queue.write_buffer(
11402                &self.globals_staging,
11403                (i as u64) * globals_bytes,
11404                bytemuck::bytes_of(&make_globals(target_w, target_h)),
11405            );
11406        }
11407
11408        let bind_mask = self.atlas_bind_group_mask();
11409        let bind_color = self.atlas_bind_group_color();
11410        let mut clip_depth: u32 = 0;
11411        let mut clip_depth_stack: Vec<u32> = Vec::new();
11412
11413        let snapshot_source = target_texture.cloned();
11414        let last_surface_pass = passes
11415            .iter()
11416            .rposition(|pass| matches!(pass.target, PassTarget::Surface));
11417        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
11418            // Populate backdrop snapshots for blends composited in this
11419            // pass: copy the parent target region into the snapshot
11420            // texture. Passes execute in order, so the parent holds the
11421            // true backdrop. Surface parents need the target texture (no
11422            // swapchain sampling mid-frame); layer parents copy from the
11423            // pool. Either way the region is parent-target pixels. Copies
11424            // for other passes stay queued (`remaining`).
11425            let needs_snapshot = pass
11426                .cmds
11427                .iter()
11428                .any(|c| matches!(c, Cmd::BlendLayer { .. }));
11429            if needs_snapshot {
11430                let copies = std::mem::take(&mut self.blend_copies);
11431                let has_surface_copy = copies.iter().any(|copy| copy.target == PassTarget::Surface);
11432                let mut resolved_surface = None;
11433                if has_surface_copy
11434                    && !self.working_space
11435                    && let (Some(msaa_view), Some(resolve_view)) =
11436                        (self.msaa_view.as_ref(), self.surface_resolve_view.as_ref())
11437                {
11438                    let resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11439                        label: Some("surface blend resolve"),
11440                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11441                            view: msaa_view,
11442                            resolve_target: Some(resolve_view),
11443                            ops: wgpu::Operations {
11444                                load: wgpu::LoadOp::Load,
11445                                store: wgpu::StoreOp::Store,
11446                            },
11447                            depth_slice: None,
11448                        })],
11449                        depth_stencil_attachment: None,
11450                        timestamp_writes: None,
11451                        occlusion_query_set: None,
11452                        multiview_mask: None,
11453                    });
11454                    drop(resolve_pass);
11455                    resolved_surface = self.surface_resolve_tex.clone();
11456                }
11457                let mut remaining = Vec::with_capacity(copies.len());
11458                for copy in copies {
11459                    if copy.pass_id != pass.id {
11460                        remaining.push(copy);
11461                        continue;
11462                    }
11463                    let BlendCopy {
11464                        blend_id,
11465                        target,
11466                        region,
11467                        ..
11468                    } = copy;
11469                    let (src_tex, tw, th) = match target {
11470                        PassTarget::Layer(parent_id) => match self.layer_pool.get(&parent_id) {
11471                            Some(lt) => (lt.texture.clone(), lt.width, lt.height),
11472                            None => continue,
11473                        },
11474                        PassTarget::Surface => {
11475                            let texture = if self.working_space {
11476                                self.ws_tex.clone()
11477                            } else if self.msaa_view.is_some() {
11478                                resolved_surface.clone()
11479                            } else {
11480                                snapshot_source.clone()
11481                            };
11482                            match texture {
11483                                Some(texture) => (texture, self.output_width, self.output_height),
11484                                None => {
11485                                    self.remove_blend_snapshot(blend_id);
11486                                    continue;
11487                                }
11488                            }
11489                        }
11490                    };
11491                    let Some(dst_tex) = self.blend_snapshot_texture(blend_id) else {
11492                        continue;
11493                    };
11494                    if !src_tex.usage().contains(wgpu::TextureUsages::COPY_SRC)
11495                        || !dst_tex.usage().contains(wgpu::TextureUsages::COPY_DST)
11496                    {
11497                        self.remove_blend_snapshot(blend_id);
11498                        continue;
11499                    }
11500                    let Some((sx, sy, cw, ch)) = checked_copy_region(region, tw, th) else {
11501                        self.remove_blend_snapshot(blend_id);
11502                        continue;
11503                    };
11504                    encoder.copy_texture_to_texture(
11505                        wgpu::TexelCopyTextureInfo {
11506                            texture: &src_tex,
11507                            mip_level: 0,
11508                            origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
11509                            aspect: wgpu::TextureAspect::All,
11510                        },
11511                        wgpu::TexelCopyTextureInfo {
11512                            texture: &dst_tex,
11513                            mip_level: 0,
11514                            origin: wgpu::Origin3d::ZERO,
11515                            aspect: wgpu::TextureAspect::All,
11516                        },
11517                        wgpu::Extent3d {
11518                            width: cw,
11519                            height: ch,
11520                            depth_or_array_layers: 1,
11521                        },
11522                    );
11523                }
11524                self.blend_copies = remaining;
11525            }
11526            let (color_view, resolve_target, depth_stencil_view, is_layer, working_target) =
11527                match pass.target {
11528                    PassTarget::Surface => {
11529                        let swap_view = target_view.clone();
11530                        let use_ws = self.working_space && self.ws_view.is_some();
11531                        let (color, resolve) = if use_ws {
11532                            let ws_view = self.ws_view.as_ref().unwrap();
11533                            if let Some(msaa_view) = &self.ws_msaa_view {
11534                                (msaa_view.clone(), Some(ws_view.clone()))
11535                            } else {
11536                                (ws_view.clone(), None)
11537                            }
11538                        } else if let Some(msaa_view) = &self.msaa_view {
11539                            // Resolve once per frame. Graphics layers split the
11540                            // surface into several passes that all accumulate into
11541                            // the same multisampled attachment, so only the last
11542                            // one can contribute to the presented image.
11543                            let resolve =
11544                                (Some(pass_index) == last_surface_pass).then(|| swap_view.clone());
11545                            (msaa_view.clone(), resolve)
11546                        } else {
11547                            (swap_view, None)
11548                        };
11549                        (
11550                            color,
11551                            resolve,
11552                            self.depth_stencil_view.clone(),
11553                            false,
11554                            use_ws,
11555                        )
11556                    }
11557                    PassTarget::Layer(layer_id) => {
11558                        if let Some(lt) = self.layer_pool.get(&layer_id) {
11559                            (
11560                                lt.view.clone(),
11561                                None,
11562                                lt.depth_stencil_view.clone(),
11563                                true,
11564                                false,
11565                            )
11566                        } else {
11567                            log::warn!("missing layer target {layer_id}");
11568                            continue;
11569                        }
11570                    }
11571                };
11572
11573            encoder.copy_buffer_to_buffer(
11574                &self.globals_staging,
11575                (pass_index as u64) * globals_bytes,
11576                &self.globals_buf,
11577                0,
11578                globals_bytes,
11579            );
11580
11581            if is_layer {
11582                clip_depth_stack.push(clip_depth);
11583                clip_depth = 0;
11584            }
11585
11586            let (tw, th) = match pass.target {
11587                PassTarget::Surface => (self.output_width, self.output_height),
11588                PassTarget::Layer(layer_id) => self
11589                    .layer_pool
11590                    .get(&layer_id)
11591                    .map(|l| (l.width, l.height))
11592                    .unwrap_or((self.output_width, self.output_height)),
11593            };
11594            let initial_scissor = clamp_scissor(
11595                pass.initial_scissor.0,
11596                pass.initial_scissor.1,
11597                pass.initial_scissor.2,
11598                pass.initial_scissor.3,
11599                tw,
11600                th,
11601            );
11602            let mut active_scissor = pass
11603                .active_scissor
11604                .map(|scissor| clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th));
11605            if active_scissor.is_some_and(|scissor| scissor.2 == 0 || scissor.3 == 0) {
11606                active_scissor = None;
11607            }
11608
11609            let pipes: &Pipelines = if is_layer {
11610                if self.working_space {
11611                    &self.working_space_layer_pipes
11612                } else {
11613                    &self.layer_pipes
11614                }
11615            } else if working_target {
11616                &self.working_space_pipes
11617            } else {
11618                &self.surface_pipes
11619            };
11620
11621            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11622                label: Some("pass"),
11623                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11624                    view: &color_view,
11625                    resolve_target: resolve_target.as_ref(),
11626                    ops: wgpu::Operations {
11627                        load: match pass.clear_color {
11628                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
11629                                r: c[0] as f64,
11630                                g: c[1] as f64,
11631                                b: c[2] as f64,
11632                                a: c[3] as f64,
11633                            }),
11634                            None => wgpu::LoadOp::Load,
11635                        },
11636                        store: wgpu::StoreOp::Store,
11637                    },
11638                    depth_slice: None,
11639                })],
11640                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
11641                    view: &depth_stencil_view,
11642                    depth_ops: None,
11643                    stencil_ops: Some(wgpu::Operations {
11644                        load: if pass.clear_color.is_some() {
11645                            wgpu::LoadOp::Clear(STENCIL_BASE)
11646                        } else {
11647                            wgpu::LoadOp::Load
11648                        },
11649                        store: wgpu::StoreOp::Store,
11650                    }),
11651                }),
11652                timestamp_writes: None,
11653                occlusion_query_set: None,
11654                multiview_mask: None,
11655            });
11656
11657            rpass.set_bind_group(0, &self.globals_bind, &[]);
11658            rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11659            let initial_draw_scissor = if initial_scissor.2 == 0 || initial_scissor.3 == 0 {
11660                (0, 0, 1, 1)
11661            } else {
11662                initial_scissor
11663            };
11664            rpass.set_scissor_rect(
11665                initial_draw_scissor.0,
11666                initial_draw_scissor.1,
11667                initial_draw_scissor.2,
11668                initial_draw_scissor.3,
11669            );
11670
11671            macro_rules! draw_simple {
11672                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
11673                    rpass.set_pipeline($pipeline);
11674                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11675                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11676                    rpass.draw(0..6, 0..$n);
11677                }};
11678            }
11679
11680            macro_rules! draw_with_bind {
11681                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
11682                    rpass.set_pipeline($pipeline);
11683                    rpass.set_bind_group(1, $bind, &[]);
11684                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11685                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11686                    rpass.draw(0..6, 0..$n);
11687                }};
11688            }
11689
11690            macro_rules! draw_indexed_mesh {
11691                ($pipeline:expr, $uoff:ident, $mesh:ident, $ioff:ident, $icnt:ident) => {{
11692                    rpass.set_pipeline($pipeline);
11693                    rpass.set_bind_group(1, &self.mesh_bind, &[$uoff as u32]);
11694                    let vbytes =
11695                        ($mesh.vertex_count as u64) * std::mem::size_of::<MeshVertex>() as u64;
11696                    rpass.set_vertex_buffer(0, $mesh.vertices.slice(0..vbytes));
11697                    let ibytes = ($mesh.index_count as u64) * std::mem::size_of::<u32>() as u64;
11698                    rpass.set_index_buffer(
11699                        $mesh.indices.slice(0..ibytes),
11700                        wgpu::IndexFormat::Uint32,
11701                    );
11702                    let ibytes = ($icnt as u64) * std::mem::size_of::<MeshInstance>() as u64;
11703                    rpass.set_vertex_buffer(1, self.mesh_instance_buf.slice($ioff..$ioff + ibytes));
11704                    rpass.draw_indexed(0..$mesh.index_count, 0, 0..$icnt);
11705                }};
11706            }
11707
11708            for cmd in pass.cmds {
11709                if active_scissor.is_none()
11710                    && !matches!(
11711                        &cmd,
11712                        Cmd::ClipPush { .. }
11713                            | Cmd::ClipPop { .. }
11714                            | Cmd::VectorClipPush { .. }
11715                            | Cmd::VectorClipPop { .. }
11716                    )
11717                {
11718                    continue;
11719                }
11720                match cmd {
11721                    Cmd::ClipPush {
11722                        off,
11723                        cnt: n,
11724                        scissor,
11725                        difference,
11726                        applied,
11727                    } => {
11728                        let scissor =
11729                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11730                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11731                        if usable {
11732                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11733                            active_scissor = Some(scissor);
11734                            rpass.set_pipeline(if difference {
11735                                &pipes.clip_dec
11736                            } else {
11737                                &pipes.clip_bin
11738                            });
11739
11740                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11741                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11742                            rpass.draw(0..6, 0..n);
11743                            if !difference {
11744                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11745                            }
11746                        } else {
11747                            active_scissor = None;
11748                        }
11749                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11750                    }
11751
11752                    Cmd::ClipPop {
11753                        off,
11754                        cnt: n,
11755                        scissor,
11756                        stencil_scissor,
11757                        difference,
11758                        applied,
11759                    } => {
11760                        let scissor =
11761                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11762                        let dec = clamp_scissor(
11763                            stencil_scissor.0,
11764                            stencil_scissor.1,
11765                            stencil_scissor.2,
11766                            stencil_scissor.3,
11767                            tw,
11768                            th,
11769                        );
11770                        if scissor.2 > 0 && scissor.3 > 0 {
11771                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11772                            active_scissor = Some(scissor);
11773                        } else {
11774                            active_scissor = None;
11775                        }
11776                        if applied && n > 0 && dec.2 > 0 && dec.3 > 0 {
11777                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11778                            rpass.set_pipeline(if difference {
11779                                &pipes.clip_bin
11780                            } else {
11781                                &pipes.clip_dec
11782                            });
11783                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11784                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11785                            rpass.draw(0..6, 0..n);
11786                            if scissor.2 > 0 && scissor.3 > 0 {
11787                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11788                            }
11789                            if !difference {
11790                                clip_depth = clip_depth.saturating_sub(1);
11791                            }
11792                        }
11793                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11794                    }
11795
11796                    Cmd::Rect { off, cnt: n } => {
11797                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
11798                    }
11799
11800                    Cmd::Border { off, cnt: n } => {
11801                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
11802                    }
11803
11804                    Cmd::GlyphsMask { off, cnt: n } => {
11805                        draw_with_bind!(
11806                            &pipes.text_mask,
11807                            self.glyph_mask.ring,
11808                            GlyphInstance,
11809                            &bind_mask,
11810                            off,
11811                            n
11812                        );
11813                    }
11814
11815                    Cmd::GlyphsColor { off, cnt: n } => {
11816                        draw_with_bind!(
11817                            &pipes.text_color,
11818                            self.glyph_color.ring,
11819                            GlyphInstance,
11820                            &bind_color,
11821                            off,
11822                            n
11823                        );
11824                    }
11825
11826                    Cmd::GlyphsVector { off, cnt: n } => {
11827                        if let Some(slug_pipe) = pipes.slug.as_ref() {
11828                            rpass.set_pipeline(slug_pipe);
11829                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
11830                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
11831                            rpass.draw(0..n, 0..1);
11832                        }
11833                    }
11834
11835                    Cmd::ImageRgba {
11836                        off,
11837                        cnt: n,
11838                        handle,
11839                        filter,
11840                        tile,
11841                        blend,
11842                    } => {
11843                        let bind_opt = match self.images.get(&handle) {
11844                            Some(ImageTex::Rgba { binds, .. }) => Some(binds.get(filter, tile)),
11845                            Some(ImageTex::User { binds, .. }) => Some(binds.get(filter, tile)),
11846                            _ => None,
11847                        };
11848                        if let Some(bind) = bind_opt {
11849                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11850                            {
11851                                Some(index) => &pipes.image_blends[index],
11852                                None => &pipes.image_rgba,
11853                            };
11854                            draw_with_bind!(
11855                                pipe,
11856                                self.glyph_color.ring,
11857                                GlyphInstance,
11858                                bind,
11859                                off,
11860                                n
11861                            );
11862                        }
11863                    }
11864                    Cmd::Coverage {
11865                        off,
11866                        cnt: n,
11867                        handle,
11868                    } => {
11869                        if let Some(tile) = self.coverages.get(&handle) {
11870                            draw_with_bind!(
11871                                &pipes.coverage,
11872                                self.glyph_color.ring,
11873                                GlyphInstance,
11874                                &tile.bind,
11875                                off,
11876                                n
11877                            );
11878                        }
11879                    }
11880
11881                    Cmd::ImageNv12 {
11882                        off,
11883                        cnt: n,
11884                        handle,
11885                        filter,
11886                        blend,
11887                    } => {
11888                        if let Some(ImageTex::Nv12 { binds, .. }) = self.images.get(&handle) {
11889                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11890                            {
11891                                Some(index) => &pipes.image_nv12_blends[index],
11892                                None => &pipes.image_nv12,
11893                            };
11894                            draw_with_bind!(
11895                                pipe,
11896                                self.nv12.ring,
11897                                Nv12Instance,
11898                                binds.get(filter, false),
11899                                off,
11900                                n
11901                            );
11902                        }
11903                    }
11904
11905                    Cmd::Ellipse { off, cnt: n } => {
11906                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
11907                    }
11908
11909                    Cmd::EllipseBorder { off, cnt: n } => {
11910                        draw_simple!(
11911                            &pipes.ellipse_borders,
11912                            self.ellipse_borders.ring,
11913                            EllipseBorderInstance,
11914                            off,
11915                            n
11916                        );
11917                    }
11918
11919                    Cmd::Arc { off, cnt: n } => {
11920                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
11921                    }
11922
11923                    Cmd::CompositeLayer {
11924                        off,
11925                        cnt: n,
11926                        layer_id,
11927                    } => {
11928                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11929                            draw_with_bind!(
11930                                &pipes.image_rgba,
11931                                self.glyph_color.ring,
11932                                GlyphInstance,
11933                                &lt.bind,
11934                                off,
11935                                n
11936                            );
11937                        }
11938                    }
11939                    Cmd::CompositeShadow {
11940                        off,
11941                        cnt: n,
11942                        layer_id,
11943                    } => {
11944                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11945                            draw_with_bind!(
11946                                &pipes.blur,
11947                                self.blur_ring,
11948                                BlurInstance,
11949                                &lt.bind_linear,
11950                                off,
11951                                n
11952                            );
11953                        }
11954                    }
11955                    Cmd::CompositeBlur {
11956                        off,
11957                        cnt: n,
11958                        layer_id,
11959                    } => {
11960                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11961                            draw_with_bind!(
11962                                &pipes.blur_content,
11963                                self.blur_ring,
11964                                BlurInstance,
11965                                &lt.bind_linear,
11966                                off,
11967                                n
11968                            );
11969                        }
11970                    }
11971                    Cmd::CompositeProjective {
11972                        off,
11973                        cnt: n,
11974                        layer_id,
11975                    } => {
11976                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11977                            // The layer texture is sampled with the rgba
11978                            // (non-linear-filter) binding, like the sharp
11979                            // composite path.
11980                            draw_with_bind!(
11981                                &pipes.projective_layer,
11982                                self.projective_ring,
11983                                ProjectiveInstance,
11984                                &lt.bind,
11985                                off,
11986                                n
11987                            );
11988                        }
11989                    }
11990
11991                    Cmd::BlendLayer {
11992                        off,
11993                        cnt: n,
11994                        src_layer,
11995                        dst_layer,
11996                        parent,
11997                        scissor,
11998                    } => {
11999                        let src = self.layer_pool.get(&src_layer).cloned();
12000                        // Backdrop is the snapshot copied from the parent
12001                        // target before this pass opened (a texture cannot be
12002                        // sampled mid-pass while bound as a render target).
12003                        // A missing snapshot (surface parent without texture,
12004                        // evicted layer) skips the draw: the mesh vanishes
12005                        // rather than misrendering.
12006                        let dst = dst_layer.and_then(|id| self.blend_snapshots.get(&id).cloned());
12007                        let in_parent = match parent {
12008                            PassTarget::Surface => !is_layer,
12009                            PassTarget::Layer(id) => {
12010                                matches!(pass.target, PassTarget::Layer(pid) if pid == id)
12011                            }
12012                        };
12013                        if let (Some(src_lt), Some(dst_snap)) = (src, dst)
12014                            && in_parent
12015                        {
12016                            let bytes = (n as u64) * std::mem::size_of::<BlendInstance>() as u64;
12017                            rpass.set_pipeline(&pipes.blend_layer);
12018                            let scissor =
12019                                clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12020                            if scissor.2 > 0 && scissor.3 > 0 {
12021                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12022                                rpass.set_bind_group(0, &self.globals_bind, &[]);
12023                                rpass.set_bind_group(1, &src_lt.bind, &[]);
12024                                rpass.set_bind_group(2, &dst_snap.bind, &[]);
12025                                rpass.set_vertex_buffer(
12026                                    0,
12027                                    self.blend_ring.buf.slice(off..off + bytes),
12028                                );
12029                                rpass.draw(0..6, 0..n);
12030                            }
12031                        }
12032                    }
12033
12034                    Cmd::VectorMesh {
12035                        mesh,
12036                        uoff,
12037                        ioff,
12038                        icnt,
12039                        blend,
12040                    } => {
12041                        let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend) {
12042                            Some(index) => &pipes.mesh_blends[index],
12043                            None => &pipes.mesh,
12044                        };
12045                        draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12046                    }
12047
12048                    Cmd::VectorOverlay {
12049                        mesh,
12050                        uoff,
12051                        ioff,
12052                        icnt,
12053                    } => {
12054                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12055                        draw_indexed_mesh!(&pipes.mesh_overlay, uoff, mesh, ioff, icnt);
12056                    }
12057
12058                    Cmd::VectorClipPush {
12059                        mesh,
12060                        uoff,
12061                        ioff,
12062                        icnt,
12063                        scissor,
12064                        difference,
12065                        applied,
12066                    } => {
12067                        let scissor =
12068                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12069                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
12070                        if usable {
12071                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12072                            active_scissor = Some(scissor);
12073                            let pipe = if difference {
12074                                &pipes.mesh_clip_dec
12075                            } else {
12076                                &pipes.mesh_clip_inc
12077                            };
12078                            draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12079                            if !difference {
12080                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
12081                            }
12082                        } else {
12083                            active_scissor = None;
12084                        }
12085                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12086                    }
12087
12088                    Cmd::VectorClipPop {
12089                        mesh,
12090                        uoff,
12091                        ioff,
12092                        icnt,
12093                        scissor,
12094                        stencil_scissor,
12095                        difference,
12096                        applied,
12097                    } => {
12098                        let scissor =
12099                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12100                        let dec = clamp_scissor(
12101                            stencil_scissor.0,
12102                            stencil_scissor.1,
12103                            stencil_scissor.2,
12104                            stencil_scissor.3,
12105                            tw,
12106                            th,
12107                        );
12108                        if scissor.2 > 0 && scissor.3 > 0 {
12109                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12110                            active_scissor = Some(scissor);
12111                        } else {
12112                            active_scissor = None;
12113                        }
12114                        if applied && dec.2 > 0 && dec.3 > 0 {
12115                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
12116                            let pipe = if difference {
12117                                &pipes.mesh_clip_inc
12118                            } else {
12119                                &pipes.mesh_clip_dec
12120                            };
12121                            draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12122                            if scissor.2 > 0 && scissor.3 > 0 {
12123                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12124                            }
12125                            if !difference {
12126                                clip_depth = clip_depth.saturating_sub(1);
12127                            }
12128                        }
12129                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12130                    }
12131
12132                    Cmd::Callback {
12133                        rect,
12134                        clip_rect,
12135                        scissor,
12136                        restore_scissor,
12137                        callback_id,
12138                        payload,
12139                    } => {
12140                        if let Some(cb) = payload.downcast_ref::<Callback>() {
12141                            // Round INWARD. A callback may only paint inside its
12142                            // own rect, and a fullscreen pass draws a single
12143                            // clip-space quad spanning twice the viewport, so an
12144                            // outward-rounded viewport overshot the rect and the
12145                            // outward-rounded scissor let that overshoot through.
12146                            let vp_x = rect.x.ceil().max(0.0);
12147                            let vp_y = rect.y.ceil().max(0.0);
12148                            let vp_w = ((rect.x + rect.w).floor() - vp_x).max(1.0);
12149                            let vp_h = ((rect.y + rect.h).floor() - vp_y).max(1.0);
12150                            if vp_w > 0.0 && vp_h > 0.0 && clip_rect.w > 0.0 && clip_rect.h > 0.0 {
12151                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12152                                rpass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
12153                                let info = repose_core::PaintCallbackInfo {
12154                                    viewport: rect,
12155                                    clip_rect,
12156                                    pixels_per_point: self.pixels_per_point,
12157                                    screen_size_px: [tw, th],
12158                                };
12159                                let descriptor = ScreenDescriptor {
12160                                    size_in_pixels: [tw, th],
12161                                    pixels_per_point: self.pixels_per_point,
12162                                    target_format: if is_layer {
12163                                        self.layer_target_format()
12164                                    } else if working_target {
12165                                        wgpu::TextureFormat::Rgba16Float
12166                                    } else {
12167                                        self.output_format
12168                                    },
12169                                    sample_count: if is_layer {
12170                                        1
12171                                    } else {
12172                                        self.active_surface_msaa_samples()
12173                                    },
12174                                };
12175                                let scope =
12176                                    callback_scope_key(cb, callback_id, pass.target, &descriptor);
12177                                let resources = self
12178                                    .callback_scoped_resources
12179                                    .get(&scope)
12180                                    .unwrap_or(&self.callback_resources);
12181                                // A panicking callback must not skip the restore below:
12182                                // the viewport/scissor would stay clamped to
12183                                // the callback rect and every later draw in the
12184                                // frame would be clipped away. Requires
12185                                // unwinding panics; under `panic = "abort"`
12186                                // nothing runs the restore.
12187                                let outcome = {
12188                                    let mut callback_pass = CallbackRenderPass::new(
12189                                        &mut rpass,
12190                                        descriptor.target_format,
12191                                        descriptor.sample_count,
12192                                    );
12193                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
12194                                        cb.0.paint(info, &mut callback_pass, resources)
12195                                    }))
12196                                };
12197                                if outcome.is_err() {
12198                                    log::warn!("paint callback panicked; state restored");
12199                                }
12200                                rpass.set_viewport(0.0, 0.0, tw as f32, th as f32, 0.0, 1.0);
12201                                let restore = if restore_scissor.2 > 0 && restore_scissor.3 > 0 {
12202                                    restore_scissor
12203                                } else {
12204                                    (0, 0, 1, 1)
12205                                };
12206                                rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
12207                                rpass.set_bind_group(0, &self.globals_bind, &[]);
12208                                rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12209                            }
12210                        } else {
12211                            log::warn!("Unknown paint callback payload");
12212                        }
12213                    }
12214                }
12215            }
12216            if is_layer {
12217                clip_depth = clip_depth_stack.pop().unwrap_or(0);
12218            }
12219        }
12220
12221        let transient_ids: HashSet<u32> = flatten_ids_used.iter().copied().collect();
12222        let producer_ids = std::mem::take(&mut self.producer_layer_ids);
12223        // A blur scratch stays bound while its source layer is live, so a stable
12224        // layer reuses its allocation instead of reallocating each frame. Once
12225        // the source is swept the scratch id is forgotten and goes with it.
12226        self.blur_scratch_ids
12227            .retain(|&(source, _), _| producer_ids.contains(&source));
12228        let live_scratch: HashSet<u32> = self.blur_scratch_ids.values().copied().collect();
12229        let stale_layers: Vec<u32> = self
12230            .layer_pool
12231            .keys()
12232            .filter_map(|id| {
12233                (!producer_ids.contains(id)
12234                    && !transient_ids.contains(id)
12235                    && !live_scratch.contains(id))
12236                .then_some(*id)
12237            })
12238            .collect();
12239        for id in stale_layers {
12240            if let Some(layer) = self.layer_pool.remove(&id) {
12241                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
12242                if layer.transient {
12243                    self.transient_layer_bytes_total =
12244                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
12245                }
12246            }
12247        }
12248        self.flatten_layer_ids = flatten_ids_used;
12249
12250        // Display pass: linear working space -> sRGB OETF -> swapchain
12251        if self.working_space
12252            && let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
12253                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
12254        {
12255            let swap_view = target_view.clone();
12256            let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
12257                label: Some("display transform"),
12258                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
12259                    view: &swap_view,
12260                    resolve_target: None,
12261                    ops: wgpu::Operations {
12262                        load: wgpu::LoadOp::Load,
12263                        store: wgpu::StoreOp::Store,
12264                    },
12265                    depth_slice: None,
12266                })],
12267                depth_stencil_attachment: None,
12268                timestamp_writes: None,
12269                occlusion_query_set: None,
12270                multiview_mask: None,
12271            });
12272            display_pass.set_pipeline(display_pipeline);
12273            display_pass.set_bind_group(1, ws_bind, &[]);
12274            display_pass.draw(0..3, 0..1);
12275        }
12276        self.recycle_blend_snapshots();
12277    }
12278
12279    /// Render a scene into an externally-provided texture view.
12280    /// Use this when embedding Repose in a host that owns the GPU.
12281    /// The host is responsible for submitting the encoder and handling present.
12282    pub fn render_to_view(
12283        &mut self,
12284        scene: &Scene,
12285        encoder: &mut wgpu::CommandEncoder,
12286        target_view: &wgpu::TextureView,
12287        width: u32,
12288        height: u32,
12289        clear_color: Option<[f64; 4]>,
12290    ) {
12291        self.resize(width, height);
12292
12293        if let Err(error) = self.begin_frame() {
12294            log::error!("render_to_view: {error:#}");
12295            return;
12296        }
12297
12298        if self.output_width == 0 || self.output_height == 0 {
12299            self.end_frame();
12300            return;
12301        }
12302
12303        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
12304        self.end_frame();
12305    }
12306}
12307
12308#[cfg(target_os = "linux")]
12309fn validate_dmabuf_plane(
12310    fd: &std::os::unix::io::OwnedFd,
12311    offset: u64,
12312    stride: u32,
12313    width: u32,
12314    height: u32,
12315    bytes_per_pixel: u32,
12316) -> anyhow::Result<u64> {
12317    if width == 0 || height == 0 || bytes_per_pixel == 0 {
12318        anyhow::bail!("DMA-BUF plane dimensions and bytes per pixel must be non-zero");
12319    }
12320    if !offset.is_multiple_of(4) || !stride.is_multiple_of(4) {
12321        anyhow::bail!("DMA-BUF offset and stride must be 4-byte aligned");
12322    }
12323    let row_bytes = (width as u64)
12324        .checked_mul(bytes_per_pixel as u64)
12325        .ok_or_else(|| anyhow::anyhow!("DMA-BUF row size overflow"))?;
12326    if (stride as u64) < row_bytes {
12327        anyhow::bail!(
12328            "DMA-BUF stride {} is smaller than row size {row_bytes}",
12329            stride
12330        );
12331    }
12332    let span = (height as u64 - 1)
12333        .checked_mul(stride as u64)
12334        .and_then(|value| value.checked_add(row_bytes))
12335        .ok_or_else(|| anyhow::anyhow!("DMA-BUF plane size overflow"))?;
12336    let file = std::fs::File::from(
12337        fd.try_clone()
12338            .map_err(|error| anyhow::anyhow!("duplicate DMA-BUF fd: {error}"))?,
12339    );
12340    let file_size = file
12341        .metadata()
12342        .map_err(|error| anyhow::anyhow!("stat DMA-BUF fd: {error}"))?
12343        .len();
12344    if offset > file_size || span > file_size - offset {
12345        anyhow::bail!("DMA-BUF plane offset/size {offset}+{span} exceeds file size {file_size}");
12346    }
12347    Ok(file_size)
12348}
12349
12350fn is_filterable_color_format(format: wgpu::TextureFormat) -> bool {
12351    matches!(
12352        format,
12353        wgpu::TextureFormat::Rgba8Unorm
12354            | wgpu::TextureFormat::Rgba8UnormSrgb
12355            | wgpu::TextureFormat::Bgra8Unorm
12356            | wgpu::TextureFormat::Bgra8UnormSrgb
12357            | wgpu::TextureFormat::Rgba16Unorm
12358            | wgpu::TextureFormat::Rgba16Float
12359            | wgpu::TextureFormat::Rgba32Float
12360            | wgpu::TextureFormat::Rgb10a2Unorm
12361    )
12362}
12363
12364fn native_format_supported(format: wgpu::TextureFormat, features: wgpu::Features) -> bool {
12365    if !is_filterable_color_format(format) {
12366        return false;
12367    }
12368    match format {
12369        wgpu::TextureFormat::Rgba16Unorm => {
12370            features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
12371        }
12372        wgpu::TextureFormat::Rgba32Float => features.contains(wgpu::Features::FLOAT32_FILTERABLE),
12373        _ => true,
12374    }
12375}
12376
12377fn validate_sampler_descriptor(
12378    device: &wgpu::Device,
12379    sampler: &wgpu::SamplerDescriptor<'_>,
12380) -> anyhow::Result<()> {
12381    if sampler.compare.is_some()
12382        || !matches!(
12383            sampler.mag_filter,
12384            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12385        )
12386        || !matches!(
12387            sampler.min_filter,
12388            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12389        )
12390        || sampler.anisotropy_clamp == 0
12391        || !sampler.lod_min_clamp.is_finite()
12392        || !sampler.lod_max_clamp.is_finite()
12393        || sampler.lod_min_clamp < 0.0
12394        || sampler.lod_max_clamp < sampler.lod_min_clamp
12395    {
12396        anyhow::bail!("native sampler must be non-comparison linear filtering with finite LODs");
12397    }
12398    if (matches!(sampler.address_mode_u, wgpu::AddressMode::ClampToBorder)
12399        || matches!(sampler.address_mode_v, wgpu::AddressMode::ClampToBorder)
12400        || matches!(sampler.address_mode_w, wgpu::AddressMode::ClampToBorder))
12401        && !device
12402            .features()
12403            .contains(wgpu::Features::ADDRESS_MODE_CLAMP_TO_BORDER)
12404    {
12405        anyhow::bail!("native sampler uses unsupported clamp-to-border addressing");
12406    }
12407    Ok(())
12408}
12409
12410fn validate_texture_dimensions(
12411    device: &wgpu::Device,
12412    width: u32,
12413    height: u32,
12414) -> anyhow::Result<()> {
12415    if width == 0
12416        || height == 0
12417        || width > device.limits().max_texture_dimension_2d
12418        || height > device.limits().max_texture_dimension_2d
12419    {
12420        anyhow::bail!("texture dimensions are outside the device limit");
12421    }
12422    Ok(())
12423}
12424
12425fn validate_image_handle(handle: u64) -> anyhow::Result<()> {
12426    if handle == 0 {
12427        anyhow::bail!("image handle 0 is reserved");
12428    }
12429    Ok(())
12430}
12431
12432/// Decoder limits for encoded uploads. `image::Limits::default()` only caps
12433/// allocations at 512 MiB, which a few hundred KB of hostile PNG can reach;
12434/// the dimension cap plus a decode-sized allocation cap keep the transient
12435/// inside the same envelope as the retained image budget.
12436fn decode_limits(max_dimension: u32) -> image::Limits {
12437    let mut limits = image::Limits::default();
12438    limits.max_image_width = Some(max_dimension);
12439    limits.max_image_height = Some(max_dimension);
12440    limits.max_alloc = Some(MAX_IMAGE_DECODE_BYTES);
12441    limits
12442}
12443
12444/// Decode an encoded image to RGBA8, rejecting oversized input *before* the
12445/// decoder allocates. The dimension probe runs against a separate reader
12446/// because `into_dimensions` consumes it.
12447fn decode_image_rgba8(data: &[u8], max_dimension: u32) -> anyhow::Result<(u32, u32, Vec<u8>)> {
12448    let format = image::guess_format(data)?;
12449    let (w, h) = {
12450        let mut probe = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12451        probe.limits(decode_limits(max_dimension));
12452        probe.into_dimensions()?
12453    };
12454    validate_image_decode_budget(w, h, max_dimension)?;
12455    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12456    reader.limits(decode_limits(max_dimension));
12457    let rgba = reader.decode()?.into_rgba8();
12458    Ok((w, h, rgba.into_raw()))
12459}
12460
12461fn validate_image_decode_budget(w: u32, h: u32, max_dimension: u32) -> anyhow::Result<()> {
12462    if w == 0 || h == 0 {
12463        anyhow::bail!("encoded image has zero dimensions");
12464    }
12465    if w > max_dimension || h > max_dimension {
12466        anyhow::bail!("encoded image {w}x{h} exceeds the device texture limit {max_dimension}");
12467    }
12468    let bytes = checked_image_bytes(w, h, 4)?;
12469    if bytes > MAX_IMAGE_DECODE_BYTES {
12470        anyhow::bail!(
12471            "encoded image {w}x{h} needs {bytes} bytes to decode, over the {MAX_IMAGE_DECODE_BYTES} byte limit"
12472        );
12473    }
12474    Ok(())
12475}
12476
12477fn checked_image_bytes(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<u64> {
12478    if w == 0 || h == 0 || bytes_per_pixel == 0 {
12479        anyhow::bail!("image dimensions and bytes per pixel must be non-zero");
12480    }
12481    (w as u64)
12482        .checked_mul(h as u64)
12483        .and_then(|size| size.checked_mul(bytes_per_pixel as u64))
12484        .ok_or_else(|| anyhow::anyhow!("image byte size overflow"))
12485}
12486
12487fn texture_storage_bytes(
12488    format: wgpu::TextureFormat,
12489    width: u32,
12490    height: u32,
12491) -> anyhow::Result<u64> {
12492    if width == 0 || height == 0 {
12493        anyhow::bail!("texture dimensions must be non-zero");
12494    }
12495    let bytes = format.theoretical_memory_footprint(wgpu::Extent3d {
12496        width,
12497        height,
12498        depth_or_array_layers: 1,
12499    });
12500    if bytes == 0 {
12501        anyhow::bail!("texture format has no storage size");
12502    }
12503    Ok(bytes)
12504}
12505
12506fn texture_storage_bytes_with_samples(
12507    format: wgpu::TextureFormat,
12508    width: u32,
12509    height: u32,
12510    sample_count: u32,
12511) -> anyhow::Result<u64> {
12512    texture_storage_bytes(format, width, height)?
12513        .checked_mul(u64::from(sample_count.max(1)))
12514        .ok_or_else(|| anyhow::anyhow!("multisample texture size overflow"))
12515}
12516
12517fn checked_image_bytes_usize(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<usize> {
12518    usize::try_from(checked_image_bytes(w, h, bytes_per_pixel)?)
12519        .map_err(|_| anyhow::anyhow!("image byte size exceeds addressable memory"))
12520}
12521
12522fn clamp_scissor(x: u32, y: u32, w: u32, h: u32, tw: u32, th: u32) -> (u32, u32, u32, u32) {
12523    if w == 0 || h == 0 || tw == 0 || th == 0 {
12524        return (0, 0, 0, 0);
12525    }
12526    let x = x.min(tw - 1);
12527    let y = y.min(th - 1);
12528    let w = w.min(tw - x);
12529    let h = h.min(th - y);
12530    (x, y, w, h)
12531}
12532
12533fn rect_to_scissor(r: repose_core::Rect, width: u32, height: u32) -> (u32, u32, u32, u32) {
12534    if width == 0
12535        || height == 0
12536        || !r.x.is_finite()
12537        || !r.y.is_finite()
12538        || !r.w.is_finite()
12539        || !r.h.is_finite()
12540        || r.w <= 0.0
12541        || r.h <= 0.0
12542    {
12543        return (0, 0, 0, 0);
12544    }
12545    // Inward, for the same reason as `to_scissor`: a clip covers only whole
12546    // pixels inside its rect.
12547    let x0 = r.x.ceil().max(0.0).min(width as f32);
12548    let y0 = r.y.ceil().max(0.0).min(height as f32);
12549    let x1 = (r.x + r.w).floor().max(x0).min(width as f32);
12550    let y1 = (r.y + r.h).floor().max(y0).min(height as f32);
12551    if x1 <= x0 || y1 <= y0 {
12552        return (0, 0, 0, 0);
12553    }
12554    (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
12555}
12556
12557fn rect_to_ndc(r: repose_core::Rect, width: f32, height: f32) -> [f32; 4] {
12558    if width <= 0.0 || height <= 0.0 || !r.x.is_finite() || !r.y.is_finite() {
12559        return [0.0, 0.0, 0.0, 0.0];
12560    }
12561    let x0 = r.x / width * 2.0 - 1.0;
12562    let y0 = 1.0 - r.y / height * 2.0;
12563    let x1 = (r.x + r.w) / width * 2.0 - 1.0;
12564    let y1 = 1.0 - (r.y + r.h) / height * 2.0;
12565    [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]
12566}
12567
12568fn parent_rect_to_layer_rect(
12569    rect: repose_core::Rect,
12570    parent_transform: &Transform,
12571    origin: (f32, f32),
12572) -> Option<repose_core::Rect> {
12573    let inverse = parent_transform.inverse_linear()?;
12574    let tx = parent_transform.translate_x;
12575    let ty = parent_transform.translate_y;
12576    let corners = [
12577        (rect.x, rect.y),
12578        (rect.x + rect.w, rect.y),
12579        (rect.x, rect.y + rect.h),
12580        (rect.x + rect.w, rect.y + rect.h),
12581    ];
12582    let mut min_x = f32::INFINITY;
12583    let mut min_y = f32::INFINITY;
12584    let mut max_x = f32::NEG_INFINITY;
12585    let mut max_y = f32::NEG_INFINITY;
12586    for (x, y) in corners {
12587        let local_x = x - tx;
12588        let local_y = y - ty;
12589        let transformed_x = inverse[0] * local_x + inverse[1] * local_y;
12590        let transformed_y = inverse[2] * local_x + inverse[3] * local_y;
12591        min_x = min_x.min(transformed_x);
12592        min_y = min_y.min(transformed_y);
12593        max_x = max_x.max(transformed_x);
12594        max_y = max_y.max(transformed_y);
12595    }
12596    let result = repose_core::Rect {
12597        x: min_x - origin.0,
12598        y: min_y - origin.1,
12599        w: (max_x - min_x).max(0.0),
12600        h: (max_y - min_y).max(0.0),
12601    };
12602    (result.x.is_finite() && result.y.is_finite() && result.w.is_finite() && result.h.is_finite())
12603        .then_some(result)
12604}
12605
12606fn parent_radii_to_layer_radii(radii: [f32; 4], parent_transform: &Transform) -> Option<[f32; 4]> {
12607    let m = parent_transform.linear();
12608    let sx = (m[0] * m[0] + m[2] * m[2]).sqrt();
12609    let sy = (m[1] * m[1] + m[3] * m[3]).sqrt();
12610    (sx.is_finite() && sy.is_finite() && sx > 1e-6 && sy > 1e-6)
12611        .then(|| [radii[0] / sx, radii[1] / sy, radii[2] / sx, radii[3] / sy])
12612}
12613
12614fn parent_affine_to_layer_affine(
12615    affine: [f32; 6],
12616    parent_transform: &Transform,
12617    origin: (f32, f32),
12618) -> Option<[f32; 6]> {
12619    let inverse = parent_transform.inverse_linear()?;
12620    let tx = affine[4] - parent_transform.translate_x;
12621    let ty = affine[5] - parent_transform.translate_y;
12622    Some([
12623        inverse[0] * affine[0] + inverse[1] * affine[2],
12624        inverse[0] * affine[1] + inverse[1] * affine[3],
12625        inverse[2] * affine[0] + inverse[3] * affine[2],
12626        inverse[2] * affine[1] + inverse[3] * affine[3],
12627        inverse[0] * tx + inverse[1] * ty - origin.0,
12628        inverse[2] * tx + inverse[3] * ty - origin.1,
12629    ])
12630}
12631
12632fn checked_copy_region(
12633    region: repose_core::Rect,
12634    target_width: u32,
12635    target_height: u32,
12636) -> Option<(u32, u32, u32, u32)> {
12637    if target_width == 0
12638        || target_height == 0
12639        || !region.x.is_finite()
12640        || !region.y.is_finite()
12641        || !region.w.is_finite()
12642        || !region.h.is_finite()
12643        || region.w <= 0.0
12644        || region.h <= 0.0
12645    {
12646        return None;
12647    }
12648    let x0 = region.x.floor().max(0.0).min(target_width as f32) as u32;
12649    let y0 = region.y.floor().max(0.0).min(target_height as f32) as u32;
12650    let x1 = (region.x + region.w)
12651        .ceil()
12652        .max(x0 as f32)
12653        .min(target_width as f32) as u32;
12654    let y1 = (region.y + region.h)
12655        .ceil()
12656        .max(y0 as f32)
12657        .min(target_height as f32) as u32;
12658    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
12659}
12660
12661fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
12662    let x0 = a.x.max(b.x);
12663    let y0 = a.y.max(b.y);
12664    let x1 = (a.x + a.w).min(b.x + b.w);
12665    let y1 = (a.y + a.h).min(b.y + b.h);
12666    repose_core::Rect {
12667        x: x0,
12668        y: y0,
12669        w: (x1 - x0).max(0.0),
12670        h: (y1 - y0).max(0.0),
12671    }
12672}
12673
12674#[cfg(test)]
12675mod tests {
12676    use super::*;
12677
12678    #[test]
12679    fn image_source_rect_uses_texel_edges() {
12680        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 3, 2)), 10, 8)
12681            .expect("source should be valid");
12682        assert_eq!(source.uv_min, [0.2, 0.125]);
12683        assert_eq!(source.uv_max, [0.5, 0.375]);
12684        assert_eq!(source.width, 3.0);
12685        assert_eq!(source.height, 2.0);
12686        assert!(resolve_image_source(Some(ImageSourceRect::new(8, 0, 3, 1)), 10, 8).is_none());
12687        assert!(resolve_image_source(Some(ImageSourceRect::new(0, 0, 0, 1)), 10, 8).is_none());
12688    }
12689
12690    #[test]
12691    fn image_fit_stays_inside_selected_source() {
12692        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 4, 2)), 10, 8)
12693            .expect("source should be valid");
12694        let rect = repose_core::Rect {
12695            x: 20.0,
12696            y: 30.0,
12697            w: 8.0,
12698            h: 8.0,
12699        };
12700        let (_, uv) = image_fit_geometry(rect, ImageFit::Cover, ImageAlignment::Center, &source)
12701            .expect("cover geometry should be valid");
12702        assert!(uv[0] >= source.uv_min[0] && uv[2] <= source.uv_max[0]);
12703        assert!(uv[1] >= source.uv_min[1] && uv[3] <= source.uv_max[1]);
12704    }
12705
12706    #[test]
12707    fn ring_alignment_rejects_bad_input() {
12708        assert_eq!(align_up(0, 4).unwrap(), 0);
12709        assert_eq!(align_up(1, 4).unwrap(), 4);
12710        assert!(align_up(1, 3).is_err());
12711        assert!(align_up(u64::MAX, 4).is_err());
12712    }
12713
12714    /// Every stride must be a legal dynamic offset and a legal `align_up`
12715    /// alignment at every alignment a driver may report.
12716    #[test]
12717    fn mesh_uniform_stride_is_always_a_power_of_two() {
12718        for base in [4u64, 16, 32, 64, 128, 256, 512] {
12719            for size in [
12720                std::mem::size_of::<MeshUniform>() as u64,
12721                96,
12722                272,
12723                288,
12724                1024,
12725            ] {
12726                let stride = mesh_uniform_stride(size, base);
12727                assert!(
12728                    stride.is_power_of_two(),
12729                    "stride {stride} for size {size} base {base} is not a power of two"
12730                );
12731                assert_eq!(stride % base, 0, "stride {stride} misaligned to {base}");
12732                assert!(stride >= size, "stride {stride} below size {size}");
12733                assert!(
12734                    align_up(size, stride).is_ok(),
12735                    "capacity alignment rejects stride {stride}"
12736                );
12737            }
12738        }
12739    }
12740
12741    /// Byte layout of one `MeshUniform`, as the shader sees it.
12742    fn wgsl_type_size(module: &naga::Module, ty: naga::Handle<naga::Type>) -> u32 {
12743        match &module.types[ty].inner {
12744            naga::TypeInner::Scalar(_) | naga::TypeInner::Atomic(_) => 4,
12745            naga::TypeInner::Vector { size, .. } => 4 * *size as u32,
12746            naga::TypeInner::Array {
12747                size: naga::ArraySize::Constant(count),
12748                stride,
12749                ..
12750            } => stride * count.get(),
12751            _ => 0,
12752        }
12753    }
12754
12755    /// `shaders/mesh.wgsl` and `MeshUniform` are two halves of one contract:
12756    /// the shader reads the struct by byte offset, so any member added,
12757    /// dropped, resized or reordered on one side only shows up as scrambled
12758    /// gradient uniforms. Compare member offsets (and the total size) directly.
12759    #[test]
12760    fn mesh_uniform_matches_the_wgsl_member_layout() {
12761        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/mesh.wgsl");
12762        let module = naga::front::wgsl::parse_str(&std::fs::read_to_string(path).expect("read"))
12763            .expect("mesh.wgsl parses");
12764        let wgsl = module
12765            .types
12766            .iter()
12767            .filter(|(_, ty)| ty.name.as_deref() == Some("MeshUniform"))
12768            .find_map(|(_, ty)| match &ty.inner {
12769                naga::TypeInner::Struct { members, .. } => Some(members.as_slice()),
12770                _ => None,
12771            })
12772            .expect("mesh.wgsl declares MeshUniform");
12773        let rust = [
12774            ("paint", std::mem::offset_of!(MeshUniform, paint)),
12775            ("color0", std::mem::offset_of!(MeshUniform, color0)),
12776            ("color1", std::mem::offset_of!(MeshUniform, color1)),
12777            ("grad_start", std::mem::offset_of!(MeshUniform, grad_start)),
12778            ("grad_end", std::mem::offset_of!(MeshUniform, grad_end)),
12779            (
12780                "stop_colors",
12781                std::mem::offset_of!(MeshUniform, stop_colors),
12782            ),
12783            (
12784                "stop_offsets",
12785                std::mem::offset_of!(MeshUniform, stop_offsets),
12786            ),
12787        ];
12788        assert_eq!(wgsl.len(), rust.len(), "MeshUniform member count");
12789        for (member, (name, offset)) in wgsl.iter().zip(rust) {
12790            assert_eq!(member.name.as_deref(), Some(name), "member order");
12791            assert_eq!(member.offset as usize, offset, "offset of {name}");
12792        }
12793        let last = wgsl.last().expect("non-empty");
12794        let wgsl_size = last.offset as usize + wgsl_type_size(&module, last.ty) as usize;
12795        assert_eq!(wgsl_size, std::mem::size_of::<MeshUniform>());
12796        assert_eq!(wgsl_size, MESH_UNIFORM_BYTES);
12797    }
12798
12799    /// The stop table rides in the same uniform as the two-point ramps, so the
12800    /// packing invariants the shader relies on (count, clamped non-decreasing
12801    /// offsets, colours left in linear space) are checked where they are built.
12802    #[test]
12803    fn gradient_stops_pack_clamped_and_monotonic() {
12804        let stops = GradientStops::from_slice(&[
12805            repose_core::GradientStop {
12806                offset: 0.25,
12807                color: repose_core::Color::from_rgba(255, 0, 0, 255),
12808            },
12809            repose_core::GradientStop {
12810                offset: f32::NAN,
12811                color: repose_core::Color::from_rgba(0, 255, 0, 255),
12812            },
12813            repose_core::GradientStop {
12814                offset: -2.0,
12815                color: repose_core::Color::from_rgba(0, 0, 255, 255),
12816            },
12817            repose_core::GradientStop {
12818                offset: 9.0,
12819                color: repose_core::Color::from_rgba(255, 255, 255, 255),
12820            },
12821        ]);
12822        let (colors, offsets, count) = pack_gradient_stops(&stops);
12823        assert_eq!(count, 4);
12824        assert_eq!(offsets, [[0.25, 0.25, 0.25, 1.0], [0.0, 0.0, 0.0, 0.0]]);
12825        assert_eq!(colors[0], [1.0, 0.0, 0.0, 1.0]);
12826        assert_eq!(colors[1], [0.0, 1.0, 0.0, 1.0]);
12827        assert_eq!(colors[2], [0.0, 0.0, 1.0, 1.0]);
12828        assert_eq!(colors[3], [1.0, 1.0, 1.0, 1.0]);
12829        assert_eq!(
12830            pack_gradient_stops(&GradientStops::default()).2,
12831            0,
12832            "an empty table paints nothing"
12833        );
12834    }
12835
12836    /// A repack moves every surviving glyph, so eviction must never touch one
12837    /// the in-flight frame already holds UVs for, and must never empty the map.
12838    /// Glyph 1 is the coldest overall but in flight, so it has to survive even
12839    /// though glyphs 3 and 4 are warmer and evictable.
12840    #[test]
12841    fn atlas_eviction_spares_the_frame_in_flight() {
12842        fn key(glyph_id: u32) -> repose_text::CacheKey {
12843            repose_text::CacheKey {
12844                font_id: 1,
12845                glyph_id,
12846                font_size_bits: 16.0f32.to_bits(),
12847                variation: None,
12848            }
12849        }
12850        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12851        for (glyph_id, last_touch, last_used_frame) in
12852            [(1u32, 10u64, 9u64), (2, 20, 7), (3, 30, 7), (4, 40, 7)]
12853        {
12854            map.insert(
12855                key(glyph_id),
12856                GlyphInfo {
12857                    u0: 0.0,
12858                    v0: 0.0,
12859                    u1: 0.1,
12860                    v1: 0.1,
12861                    w: 10.0,
12862                    h: 10.0,
12863                    last_touch,
12864                    last_used_frame,
12865                },
12866            );
12867        }
12868        let mut used_area = 400;
12869        assert!(evict_atlas_lru(&mut map, &mut used_area, 9, 250));
12870        assert_eq!(used_area, 200);
12871        assert!(map.contains_key(&key(1)), "in-flight glyph was evicted");
12872        assert!(!map.contains_key(&key(2)) && !map.contains_key(&key(3)));
12873        assert!(map.contains_key(&key(4)));
12874    }
12875
12876    #[test]
12877    fn atlas_eviction_refuses_to_empty_the_atlas() {
12878        let only = repose_text::CacheKey {
12879            font_id: 1,
12880            glyph_id: 1,
12881            font_size_bits: 16.0f32.to_bits(),
12882            variation: None,
12883        };
12884        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12885        map.insert(
12886            only,
12887            GlyphInfo {
12888                u0: 0.0,
12889                v0: 0.0,
12890                u1: 0.1,
12891                v1: 0.1,
12892                w: 10.0,
12893                h: 10.0,
12894                last_touch: 1,
12895                last_used_frame: 1,
12896            },
12897        );
12898        let mut used_area = 100;
12899        assert!(!evict_atlas_lru(&mut map, &mut used_area, 2, 0));
12900        assert_eq!(map.len(), 1);
12901        assert_eq!(used_area, 100);
12902    }
12903
12904    #[test]
12905    fn empty_and_negative_rects_are_suppressed() {
12906        assert_eq!(
12907            rect_to_scissor(
12908                repose_core::Rect {
12909                    x: 0.0,
12910                    y: 0.0,
12911                    w: 0.0,
12912                    h: 4.0,
12913                },
12914                16,
12915                16,
12916            ),
12917            (0, 0, 0, 0)
12918        );
12919        assert_eq!(
12920            rect_to_scissor(
12921                repose_core::Rect {
12922                    x: 0.0,
12923                    y: 0.0,
12924                    w: -1.0,
12925                    h: 4.0,
12926                },
12927                16,
12928                16,
12929            ),
12930            (0, 0, 0, 0)
12931        );
12932    }
12933
12934    #[test]
12935    fn copy_region_is_checked() {
12936        assert_eq!(
12937            checked_copy_region(
12938                repose_core::Rect {
12939                    x: 2.25,
12940                    y: 3.5,
12941                    w: 4.0,
12942                    h: 5.0,
12943                },
12944                16,
12945                16,
12946            ),
12947            Some((2, 3, 5, 6))
12948        );
12949        assert_eq!(
12950            checked_copy_region(
12951                repose_core::Rect {
12952                    x: 20.0,
12953                    y: 0.0,
12954                    w: 2.0,
12955                    h: 2.0,
12956                },
12957                16,
12958                16,
12959            ),
12960            None
12961        );
12962    }
12963
12964    #[test]
12965    fn decode_budget_rejects_oversized_encoded_images() {
12966        assert!(validate_image_decode_budget(64, 64, 8192).is_ok());
12967        assert!(validate_image_decode_budget(0, 64, 8192).is_err());
12968        assert!(validate_image_decode_budget(64, 0, 8192).is_err());
12969        assert!(validate_image_decode_budget(8193, 1, 8192).is_err());
12970        assert!(validate_image_decode_budget(1, 8193, 8192).is_err());
12971        // Fits the device, but not the decode allocation budget.
12972        assert!(validate_image_decode_budget(8192, 4096, 8192).is_err());
12973    }
12974
12975    #[test]
12976    fn only_wgpu_supported_sample_counts_are_accepted() {
12977        for samples in [1, 2, 4, 8, 16] {
12978            assert!(validate_sample_count("msaa_samples", samples).is_ok());
12979        }
12980        for samples in [0, 3, 5, 7, 9, 17, 32, u32::MAX] {
12981            assert!(
12982                validate_sample_count("msaa_samples", samples).is_err(),
12983                "{samples} must be rejected"
12984            );
12985        }
12986    }
12987
12988    #[test]
12989    fn p010_uses_ten_bit_sample_range() {
12990        let raw = make_yuv_transform_raw(ColorInfo::default(), PixelFormat::P010);
12991        let expected = 65535.0 / (64.0 * 1020.0);
12992        assert!((raw.b[3] - expected).abs() < 1e-6);
12993        assert_eq!(canonical_fourcc(PixelFormat::P010), P010_FOURCC);
12994        assert_eq!(canonical_fourcc(PixelFormat::Nv12), NV12_FOURCC);
12995        assert_eq!(
12996            make_yuv_transform_raw(
12997                ColorInfo {
12998                    transfer: repose_core::color::Transfer::Pq,
12999                    ..ColorInfo::default()
13000                },
13001                PixelFormat::Nv12,
13002            )
13003            .transfer[0],
13004            3.0
13005        );
13006        assert_eq!(
13007            make_yuv_transform_raw(
13008                ColorInfo {
13009                    transfer: repose_core::color::Transfer::Hlg,
13010                    ..ColorInfo::default()
13011                },
13012                PixelFormat::Nv12,
13013            )
13014            .transfer[0],
13015            4.0
13016        );
13017    }
13018}