Skip to main content

repose_render_wgpu/
lib.rs

1use std::any::TypeId;
2use std::borrow::Cow;
3use std::collections::{HashMap, HashSet};
4use std::hash::Hash;
5use std::num::NonZeroU64;
6#[cfg(feature = "winit-surface")]
7use std::panic::{AssertUnwindSafe, catch_unwind};
8use std::sync::{Arc, Mutex, Weak};
9
10use repose_core::color::{ChromaSiting, ColorInfo, PixelFormat};
11use repose_core::{
12    Brush, FontStyle, GradientStops, ImageAlignment, ImageFilter, ImageFit, ImagePaintStyle,
13    ImageSourceRect, MAX_GRADIENT_STOPS, Scene, SceneNode, StrokeCap, TextPaintStyle, Transform,
14    Vec2,
15};
16#[cfg(feature = "winit-surface")]
17use repose_core::{GlyphRasterConfig, PresentModePref, RenderBackend, request_frame};
18#[cfg(feature = "winit-surface")]
19use wgpu::Instance;
20
21mod slug;
22
23fn align_up(value: u64, alignment: u64) -> anyhow::Result<u64> {
24    if !alignment.is_power_of_two() {
25        anyhow::bail!("alignment must be a power of two");
26    }
27    value
28        .checked_add(alignment - 1)
29        .map(|v| v & !(alignment - 1))
30        .ok_or_else(|| anyhow::anyhow!("alignment overflow"))
31}
32
33/// Stride between per-draw uniform slots. Only the slot start must satisfy the
34/// device's `min_uniform_buffer_offset_alignment`, so round to the next power
35/// of two above that: always a legal offset, and a power of two so
36/// [`align_up`] can size the buffer. Rounding to the bare minimum (272 -> 288
37/// at the common 32-byte alignment) produced a non-power-of-two stride, which
38/// made `align_up` reject the capacity and collapse the buffer to one slot, so
39/// every mesh after the first in a frame was silently dropped.
40fn mesh_uniform_stride(size: u64, base: u64) -> u64 {
41    let rounded = size.saturating_add(base.saturating_sub(1)) / base * base;
42    rounded.next_power_of_two().max(base)
43}
44
45mod commands;
46pub use commands::RenderCommandFailure;
47pub use commands::RenderCommandReport;
48pub use commands::apply_render_commands;
49
50pub mod offscreen;
51
52mod callback;
53pub use callback::{
54    Callback, CallbackRenderPass, CallbackResources, ScreenDescriptor, WgpuCallback,
55};
56
57mod depth_composite;
58pub use depth_composite::{BlitRenderPass, DepthComposite};
59
60#[derive(Clone)]
61struct UploadRing {
62    buf: wgpu::Buffer,
63    cap: u64,
64    head: u64,
65    usage: wgpu::BufferUsages,
66}
67
68impl UploadRing {
69    fn new(device: &wgpu::Device, label: &str, cap: u64, usage: wgpu::BufferUsages) -> Self {
70        let max = device.limits().max_buffer_size;
71        let cap = cap.max(4).min(max).min(MAX_UPLOAD_RING_BYTES).max(4);
72        let buf = device.create_buffer(&wgpu::BufferDescriptor {
73            label: Some(label),
74            size: cap,
75            usage,
76            mapped_at_creation: false,
77        });
78        Self {
79            buf,
80            cap,
81            head: 0,
82            usage,
83        }
84    }
85
86    fn reset(&mut self) {
87        self.head = 0;
88    }
89
90    fn grow_to_fit(
91        &mut self,
92        device: &wgpu::Device,
93        encoder: &mut wgpu::CommandEncoder,
94        needed: u64,
95    ) -> anyhow::Result<()> {
96        if needed == 0 {
97            return Ok(());
98        }
99        let start = align_up(self.head, 4)?;
100        let end = start
101            .checked_add(needed)
102            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
103        if end <= self.cap {
104            return Ok(());
105        }
106        let required = align_up(end, 4)?;
107        let doubled = self.cap.checked_mul(2).unwrap_or(0);
108        let new_cap = required.max(doubled).max(256);
109        let new_cap = align_up(new_cap, 4)?;
110        if new_cap > device.limits().max_buffer_size || new_cap > MAX_UPLOAD_RING_BYTES {
111            anyhow::bail!("upload ring exceeds resource budget");
112        }
113        if !self.usage.contains(wgpu::BufferUsages::COPY_SRC) {
114            anyhow::bail!("upload ring growth requires COPY_SRC");
115        }
116        let old = self.buf.clone();
117        self.buf = device.create_buffer(&wgpu::BufferDescriptor {
118            label: Some("upload ring (grown)"),
119            size: new_cap,
120            usage: self.usage,
121            mapped_at_creation: false,
122        });
123        self.cap = new_cap;
124        if self.head > 0 {
125            let copy_size = align_up(self.head, 4)?;
126            if copy_size > old.size() {
127                anyhow::bail!("upload ring old data exceeds old buffer");
128            }
129            encoder.copy_buffer_to_buffer(&old, 0, &self.buf, 0, copy_size);
130        }
131        Ok(())
132    }
133
134    fn alloc_write(&mut self, queue: &wgpu::Queue, bytes: &[u8]) -> anyhow::Result<u64> {
135        let len = u64::try_from(bytes.len())
136            .map_err(|_| anyhow::anyhow!("upload byte length exceeds u64"))?;
137        let start = align_up(self.head, 4)?;
138        let end = start
139            .checked_add(len)
140            .ok_or_else(|| anyhow::anyhow!("upload ring offset overflow"))?;
141        if end > self.cap {
142            anyhow::bail!(
143                "upload ring overflow: start={start} len={len} cap={}",
144                self.cap
145            );
146        }
147        queue.write_buffer(&self.buf, start, bytes);
148        self.head = end;
149        Ok(start)
150    }
151}
152
153struct InstancedPipe<I: bytemuck::Pod> {
154    ring: UploadRing,
155    _marker: std::marker::PhantomData<I>,
156}
157
158impl<I: bytemuck::Pod> InstancedPipe<I> {
159    fn new(ring: UploadRing) -> Self {
160        Self {
161            ring,
162            _marker: std::marker::PhantomData,
163        }
164    }
165
166    fn upload(
167        &mut self,
168        device: &wgpu::Device,
169        queue: &wgpu::Queue,
170        encoder: &mut wgpu::CommandEncoder,
171        data: &[I],
172    ) -> Option<(u64, u32)> {
173        if data.is_empty() {
174            return None;
175        }
176        let count = u32::try_from(data.len()).ok()?;
177        let bytes = bytemuck::cast_slice(data);
178        let byte_len = u64::try_from(bytes.len()).ok()?;
179        if byte_len == 0 || byte_len > device.limits().max_buffer_size {
180            return None;
181        }
182        self.ring
183            .grow_to_fit(device, encoder, byte_len)
184            .map_err(|error| log::error!("{error:#}"))
185            .ok()?;
186        let off = self
187            .ring
188            .alloc_write(queue, bytes)
189            .map_err(|error| log::error!("{error:#}"))
190            .ok()?;
191        Some((off, count))
192    }
193
194    fn reset(&mut self) {
195        self.ring.reset();
196    }
197}
198
199#[repr(C)]
200#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
201struct Globals {
202    ndc_to_px: [f32; 2],
203    _pad: [f32; 2],
204}
205
206fn make_globals(target_w: f32, target_h: f32) -> Globals {
207    Globals {
208        ndc_to_px: [target_w * 0.5, target_h * 0.5],
209        _pad: [0.0, 0.0],
210    }
211}
212
213pub struct WgpuSceneRenderer {
214    pub device: wgpu::Device,
215    pub queue: wgpu::Queue,
216    pub output_format: wgpu::TextureFormat,
217    /// Texture format of the surface the multisampled attachment resolves into.
218    surface_format: wgpu::TextureFormat,
219    pub output_width: u32,
220    pub output_height: u32,
221    /// Pixels per point (DPI scale) for `ScreenDescriptor` / `PaintCallbackInfo`.
222    pub pixels_per_point: f32,
223
224    // Render pipelines. Two sets: one for the MSAA surface pass, one for
225    // graphics-layer render-to-texture passes (sample_count = 1).
226    surface_pipes: Pipelines,
227    working_space_pipes: Pipelines,
228    layer_pipes: Pipelines,
229    working_space_layer_pipes: Pipelines,
230
231    // Instanced draw rings
232    rects: InstancedPipe<RectInstance>,
233    borders: InstancedPipe<BorderInstance>,
234    ellipses: InstancedPipe<EllipseInstance>,
235    ellipse_borders: InstancedPipe<EllipseBorderInstance>,
236    arcs: InstancedPipe<ArcInstance>,
237    glyph_mask: InstancedPipe<GlyphInstance>,
238    glyph_color: InstancedPipe<GlyphInstance>,
239
240    // Image bind layouts and shared sampler
241    image_bind_layout_rgba: wgpu::BindGroupLayout,
242    image_bind_layout_nv12: wgpu::BindGroupLayout,
243    image_sampler: wgpu::Sampler,
244    image_sampler_nearest: wgpu::Sampler,
245    image_sampler_repeat: wgpu::Sampler,
246    image_sampler_repeat_nearest: wgpu::Sampler,
247    layer_sampler: wgpu::Sampler,
248    layer_sampler_linear: wgpu::Sampler,
249
250    // Blur composite ring (for graphics-layer drop shadows)
251    blur_ring: UploadRing,
252
253    text_bind_layout: wgpu::BindGroupLayout,
254
255    // Stencil clip ring
256    clip_ring: UploadRing,
257
258    // Projective layer-composite ring (one ProjectiveInstance per flattened
259    // perspective subtree)
260    projective_ring: UploadRing,
261
262    // Backdrop-blend composite ring (one BlendInstance per isolated blend)
263    blend_ring: UploadRing,
264
265    // Tessellated vector glyph pipeline (always enabled)
266    slug_enabled: bool,
267    slug_ring: UploadRing,
268    slug_cache: slug::GlyphSlugCache,
269    slug_draw_cache: HashMap<SlugDrawKey, SlugDrawCacheEntry>,
270    slug_draw_cache_bytes_total: u64,
271
272    // Instanced NV12 ring
273    nv12: InstancedPipe<Nv12Instance>,
274
275    mesh_cache: HashMap<MeshCacheKey, MeshCacheEntry>,
276    mesh_cache_bytes_total: u64,
277    mesh_uniform_buf: wgpu::Buffer,
278    mesh_bind: wgpu::BindGroup,
279    mesh_uniform_head: u64,
280    mesh_uniform_alignment: u64,
281    mesh_uniform_cap: u64,
282    /// 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        Self::from_device_with_working_space_msaa(
3087            device,
3088            queue,
3089            output_format,
3090            msaa,
3091            working_space_msaa,
3092        )
3093    }
3094
3095    /// Point the renderer at a surface whose texture format differs from
3096    /// [`Self::output_format`], as a canvas configured with `viewFormats` does.
3097    /// Call this before the first frame.
3098    pub fn with_surface_format(
3099        mut self,
3100        surface_format: wgpu::TextureFormat,
3101    ) -> anyhow::Result<Self> {
3102        if surface_format != self.output_format
3103            && surface_format.add_srgb_suffix() != self.output_format
3104        {
3105            anyhow::bail!(
3106                "surface format {surface_format:?} cannot be reinterpreted as {:?}",
3107                self.output_format
3108            );
3109        }
3110        if self.surface_format != surface_format {
3111            self.surface_format = surface_format;
3112            self.recreate_msaa_and_depth_stencil();
3113        }
3114        Ok(self)
3115    }
3116
3117    pub fn from_device_with_working_space_msaa(
3118        device: wgpu::Device,
3119        queue: wgpu::Queue,
3120        output_format: wgpu::TextureFormat,
3121        msaa_samples: u32,
3122        working_space_msaa_samples: u32,
3123    ) -> anyhow::Result<Self> {
3124        validate_sample_count("msaa_samples", msaa_samples)?;
3125        validate_sample_count("working_space_msaa_samples", working_space_msaa_samples)?;
3126        let msaa_samples = msaa_samples.max(1);
3127        let working_space_msaa_samples = working_space_msaa_samples.max(1);
3128        let mesh_uniform_size = std::mem::size_of::<MeshUniform>() as u64;
3129        let base_mesh_alignment =
3130            u64::from(device.limits().min_uniform_buffer_offset_alignment).max(4);
3131        let mesh_uniform_alignment = mesh_uniform_stride(mesh_uniform_size, base_mesh_alignment);
3132        let mesh_instance_cap = MESH_INSTANCE_CAP
3133            .min(device.limits().max_buffer_size)
3134            .max(4);
3135        let mesh_uniform_cap = align_up(
3136            MESH_UNIFORM_CAP
3137                .min(device.limits().max_buffer_size)
3138                .max(mesh_uniform_alignment),
3139            base_mesh_alignment,
3140        )
3141        .unwrap_or(mesh_uniform_alignment);
3142        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3143            label: Some("globals layout"),
3144            entries: &[wgpu::BindGroupLayoutEntry {
3145                binding: 0,
3146                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3147                ty: wgpu::BindingType::Buffer {
3148                    ty: wgpu::BufferBindingType::Uniform,
3149                    has_dynamic_offset: false,
3150                    min_binding_size: None,
3151                },
3152                count: None,
3153            }],
3154        });
3155
3156        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
3157            label: Some("globals buf"),
3158            size: std::mem::size_of::<Globals>() as u64,
3159            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3160            mapped_at_creation: false,
3161        });
3162
3163        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3164            label: Some("globals bind"),
3165            layout: &globals_layout,
3166            entries: &[wgpu::BindGroupEntry {
3167                binding: 0,
3168                resource: globals_buf.as_entire_binding(),
3169            }],
3170        });
3171        let globals_staging_cap =
3172            (64 * std::mem::size_of::<Globals>() as u64).max(std::mem::size_of::<Globals>() as u64);
3173        let globals_staging = device.create_buffer(&wgpu::BufferDescriptor {
3174            label: Some("globals staging"),
3175            size: globals_staging_cap,
3176            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
3177            mapped_at_creation: false,
3178        });
3179
3180        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
3181
3182        let stencil_for_content = wgpu::DepthStencilState {
3183            format: ds_format,
3184            depth_write_enabled: Some(false),
3185            depth_compare: Some(wgpu::CompareFunction::Always),
3186            stencil: wgpu::StencilState {
3187                front: wgpu::StencilFaceState {
3188                    // Equal (not LessEqual): inverse (`Difference`) vector
3189                    // masks work by keeping the depth while incrementing the
3190                    // masked pixels, so content must test exact equality.
3191                    // Outcomes match LessEqual everywhere except pre-existing
3192                    // stencil leaks, which now fail visibly instead of
3193                    // drawing through (unbalanced clips already warn).
3194                    compare: wgpu::CompareFunction::Equal,
3195                    fail_op: wgpu::StencilOperation::Keep,
3196                    depth_fail_op: wgpu::StencilOperation::Keep,
3197                    pass_op: wgpu::StencilOperation::Keep,
3198                },
3199                back: wgpu::StencilFaceState {
3200                    compare: wgpu::CompareFunction::Equal,
3201                    fail_op: wgpu::StencilOperation::Keep,
3202                    depth_fail_op: wgpu::StencilOperation::Keep,
3203                    pass_op: wgpu::StencilOperation::Keep,
3204                },
3205                read_mask: 0xFF,
3206                write_mask: 0x00,
3207            },
3208            bias: wgpu::DepthBiasState::default(),
3209        };
3210
3211        let stencil_for_clip_inc = wgpu::DepthStencilState {
3212            format: ds_format,
3213            depth_write_enabled: Some(false),
3214            depth_compare: Some(wgpu::CompareFunction::Always),
3215            stencil: wgpu::StencilState {
3216                front: wgpu::StencilFaceState {
3217                    compare: wgpu::CompareFunction::Always,
3218                    fail_op: wgpu::StencilOperation::Keep,
3219                    depth_fail_op: wgpu::StencilOperation::Keep,
3220                    pass_op: wgpu::StencilOperation::IncrementClamp,
3221                },
3222                back: wgpu::StencilFaceState {
3223                    compare: wgpu::CompareFunction::Always,
3224                    fail_op: wgpu::StencilOperation::Keep,
3225                    depth_fail_op: wgpu::StencilOperation::Keep,
3226                    pass_op: wgpu::StencilOperation::IncrementClamp,
3227                },
3228                read_mask: 0xFF,
3229                write_mask: 0xFF,
3230            },
3231            bias: wgpu::DepthBiasState::default(),
3232        };
3233
3234        let stencil_for_clip_dec = wgpu::DepthStencilState {
3235            format: ds_format,
3236            depth_write_enabled: Some(false),
3237            depth_compare: Some(wgpu::CompareFunction::Always),
3238            stencil: wgpu::StencilState {
3239                front: wgpu::StencilFaceState {
3240                    compare: wgpu::CompareFunction::Always,
3241                    fail_op: wgpu::StencilOperation::Keep,
3242                    depth_fail_op: wgpu::StencilOperation::Keep,
3243                    pass_op: wgpu::StencilOperation::DecrementClamp,
3244                },
3245                back: wgpu::StencilFaceState {
3246                    compare: wgpu::CompareFunction::Always,
3247                    fail_op: wgpu::StencilOperation::Keep,
3248                    depth_fail_op: wgpu::StencilOperation::Keep,
3249                    pass_op: wgpu::StencilOperation::DecrementClamp,
3250                },
3251                read_mask: 0xFF,
3252                write_mask: 0xFF,
3253            },
3254            bias: wgpu::DepthBiasState::default(),
3255        };
3256
3257        let _multisample_state = wgpu::MultisampleState {
3258            count: msaa_samples,
3259            mask: !0,
3260            alpha_to_coverage_enabled: false,
3261        };
3262
3263        // PIPELINES
3264
3265        // Single shared sampler for images/text
3266        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3267            label: Some("image/text sampler"),
3268            address_mode_u: wgpu::AddressMode::ClampToEdge,
3269            address_mode_v: wgpu::AddressMode::ClampToEdge,
3270            mag_filter: wgpu::FilterMode::Linear,
3271            min_filter: wgpu::FilterMode::Linear,
3272            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3273            ..Default::default()
3274        });
3275        let image_sampler_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3276            label: Some("image nearest sampler"),
3277            address_mode_u: wgpu::AddressMode::ClampToEdge,
3278            address_mode_v: wgpu::AddressMode::ClampToEdge,
3279            mag_filter: wgpu::FilterMode::Nearest,
3280            min_filter: wgpu::FilterMode::Nearest,
3281            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3282            ..Default::default()
3283        });
3284
3285        // `ImageFit::Tile` emits UVs past 1.0 and relies on wrapping, so it
3286        // needs its own samplers rather than the clamped pair above.
3287        let image_sampler_repeat = device.create_sampler(&wgpu::SamplerDescriptor {
3288            label: Some("image repeat sampler"),
3289            address_mode_u: wgpu::AddressMode::Repeat,
3290            address_mode_v: wgpu::AddressMode::Repeat,
3291            mag_filter: wgpu::FilterMode::Linear,
3292            min_filter: wgpu::FilterMode::Linear,
3293            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3294            ..Default::default()
3295        });
3296        let image_sampler_repeat_nearest = device.create_sampler(&wgpu::SamplerDescriptor {
3297            label: Some("image repeat nearest sampler"),
3298            address_mode_u: wgpu::AddressMode::Repeat,
3299            address_mode_v: wgpu::AddressMode::Repeat,
3300            mag_filter: wgpu::FilterMode::Nearest,
3301            min_filter: wgpu::FilterMode::Nearest,
3302            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3303            ..Default::default()
3304        });
3305
3306        // linear filtering only blurs them; nearest keeps the blit crisp.
3307        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3308            label: Some("layer nearest sampler"),
3309            address_mode_u: wgpu::AddressMode::ClampToEdge,
3310            address_mode_v: wgpu::AddressMode::ClampToEdge,
3311            mag_filter: wgpu::FilterMode::Nearest,
3312            min_filter: wgpu::FilterMode::Nearest,
3313            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3314            ..Default::default()
3315        });
3316
3317        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
3318        // the sharp 1:1 layer composite.
3319        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
3320            label: Some("layer linear sampler"),
3321            address_mode_u: wgpu::AddressMode::ClampToEdge,
3322            address_mode_v: wgpu::AddressMode::ClampToEdge,
3323            mag_filter: wgpu::FilterMode::Linear,
3324            min_filter: wgpu::FilterMode::Linear,
3325            mipmap_filter: wgpu::MipmapFilterMode::Linear,
3326            ..Default::default()
3327        });
3328
3329        // Layout for Text / RGBA Images (Texture + Sampler)
3330        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3331            label: Some("text/rgba bind layout"),
3332            entries: &[
3333                wgpu::BindGroupLayoutEntry {
3334                    binding: 0,
3335                    visibility: wgpu::ShaderStages::FRAGMENT,
3336                    ty: wgpu::BindingType::Texture {
3337                        multisampled: false,
3338                        view_dimension: wgpu::TextureViewDimension::D2,
3339                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
3340                    },
3341                    count: None,
3342                },
3343                wgpu::BindGroupLayoutEntry {
3344                    binding: 1,
3345                    visibility: wgpu::ShaderStages::FRAGMENT,
3346                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3347                    count: None,
3348                },
3349            ],
3350        });
3351        // We reuse this for RGBA images for simplicity, or create a distinct one
3352        let image_bind_layout_rgba = text_bind_layout.clone();
3353
3354        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
3355        let image_bind_layout_nv12 =
3356            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3357                label: Some("image bind layout nv12"),
3358                entries: &[
3359                    // Y plane
3360                    wgpu::BindGroupLayoutEntry {
3361                        binding: 0,
3362                        visibility: wgpu::ShaderStages::FRAGMENT,
3363                        ty: wgpu::BindingType::Texture {
3364                            multisampled: false,
3365                            view_dimension: wgpu::TextureViewDimension::D2,
3366                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3367                        },
3368                        count: None,
3369                    },
3370                    // UV plane
3371                    wgpu::BindGroupLayoutEntry {
3372                        binding: 1,
3373                        visibility: wgpu::ShaderStages::FRAGMENT,
3374                        ty: wgpu::BindingType::Texture {
3375                            multisampled: false,
3376                            view_dimension: wgpu::TextureViewDimension::D2,
3377                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
3378                        },
3379                        count: None,
3380                    },
3381                    // Sampler
3382                    wgpu::BindGroupLayoutEntry {
3383                        binding: 2,
3384                        visibility: wgpu::ShaderStages::FRAGMENT,
3385                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
3386                        count: None,
3387                    },
3388                    // YUV transform uniform buffer
3389                    wgpu::BindGroupLayoutEntry {
3390                        binding: 3,
3391                        visibility: wgpu::ShaderStages::FRAGMENT,
3392                        ty: wgpu::BindingType::Buffer {
3393                            ty: wgpu::BufferBindingType::Uniform,
3394                            has_dynamic_offset: false,
3395                            min_binding_size: None,
3396                        },
3397                        count: None,
3398                    },
3399                ],
3400            });
3401
3402        // Clipping layout
3403        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3404            label: Some("clip pipeline layout"),
3405            bind_group_layouts: &[Some(&globals_layout)],
3406            immediate_size: 0,
3407        });
3408        let clip_vertex_layout = wgpu::VertexBufferLayout {
3409            array_stride: std::mem::size_of::<ClipInstance>() as u64,
3410            step_mode: wgpu::VertexStepMode::Instance,
3411            attributes: &[
3412                wgpu::VertexAttribute {
3413                    shader_location: 0,
3414                    offset: 0,
3415                    format: wgpu::VertexFormat::Float32x4,
3416                },
3417                wgpu::VertexAttribute {
3418                    shader_location: 1,
3419                    offset: 16,
3420                    format: wgpu::VertexFormat::Float32x4,
3421                },
3422                wgpu::VertexAttribute {
3423                    shader_location: 2,
3424                    offset: 32,
3425                    format: wgpu::VertexFormat::Float32x4,
3426                },
3427            ],
3428        };
3429        // Bind layout for per-draw vector mesh uniforms (dynamic offset).
3430        let mesh_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3431            label: Some("mesh uniform layout"),
3432            entries: &[wgpu::BindGroupLayoutEntry {
3433                binding: 0,
3434                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
3435                ty: wgpu::BindingType::Buffer {
3436                    ty: wgpu::BufferBindingType::Uniform,
3437                    has_dynamic_offset: true,
3438                    min_binding_size: NonZeroU64::new(mesh_uniform_size),
3439                },
3440                count: None,
3441            }],
3442        });
3443        let mesh_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
3444            label: Some("mesh uniform buffer"),
3445            size: mesh_uniform_cap,
3446            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3447            mapped_at_creation: false,
3448        });
3449        let mesh_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
3450            label: Some("mesh instance buffer"),
3451            size: mesh_instance_cap,
3452            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3453            mapped_at_creation: false,
3454        });
3455        let mesh_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3456            label: Some("mesh uniform bind"),
3457            layout: &mesh_bind_layout,
3458            entries: &[wgpu::BindGroupEntry {
3459                binding: 0,
3460                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3461                    buffer: &mesh_uniform_buf,
3462                    offset: 0,
3463                    size: NonZeroU64::new(mesh_uniform_size),
3464                }),
3465            }],
3466        });
3467
3468        // Two sets of pipelines: one for the MSAA surface pass, one for layer
3469        // render-to-texture passes (sample_count = 1).
3470        let surface_pipes = Pipelines::create(
3471            &device,
3472            output_format,
3473            msaa_samples,
3474            &globals_layout,
3475            &text_bind_layout,
3476            &image_bind_layout_nv12,
3477            &clip_pipeline_layout,
3478            &stencil_for_content,
3479            &stencil_for_clip_inc,
3480            &stencil_for_clip_dec,
3481            &clip_vertex_layout,
3482            &mesh_bind_layout,
3483        );
3484        let working_space_pipes = Pipelines::create(
3485            &device,
3486            wgpu::TextureFormat::Rgba16Float,
3487            working_space_msaa_samples,
3488            &globals_layout,
3489            &text_bind_layout,
3490            &image_bind_layout_nv12,
3491            &clip_pipeline_layout,
3492            &stencil_for_content,
3493            &stencil_for_clip_inc,
3494            &stencil_for_clip_dec,
3495            &clip_vertex_layout,
3496            &mesh_bind_layout,
3497        );
3498        let layer_pipes = if msaa_samples == 1 {
3499            surface_pipes.clone()
3500        } else {
3501            Pipelines::create(
3502                &device,
3503                output_format,
3504                1,
3505                &globals_layout,
3506                &text_bind_layout,
3507                &image_bind_layout_nv12,
3508                &clip_pipeline_layout,
3509                &stencil_for_content,
3510                &stencil_for_clip_inc,
3511                &stencil_for_clip_dec,
3512                &clip_vertex_layout,
3513                &mesh_bind_layout,
3514            )
3515        };
3516        let working_space_layer_pipes = if working_space_msaa_samples == 1 {
3517            working_space_pipes.clone()
3518        } else {
3519            Pipelines::create(
3520                &device,
3521                wgpu::TextureFormat::Rgba16Float,
3522                1,
3523                &globals_layout,
3524                &text_bind_layout,
3525                &image_bind_layout_nv12,
3526                &clip_pipeline_layout,
3527                &stencil_for_content,
3528                &stencil_for_clip_inc,
3529                &stencil_for_clip_dec,
3530                &clip_vertex_layout,
3531                &mesh_bind_layout,
3532            )
3533        };
3534
3535        // Vector glyph rendering always available with tessellation+MSAA approach.
3536        let slug_enabled = true;
3537
3538        // Blur composite ring (for graphics-layer drop shadows)
3539        let blur_ring = UploadRing::new(
3540            &device,
3541            "blur ring",
3542            1024 * 1024,
3543            wgpu::BufferUsages::VERTEX
3544                | wgpu::BufferUsages::COPY_DST
3545                | wgpu::BufferUsages::COPY_SRC,
3546        );
3547
3548        // Atlases
3549        let atlas_mask = init_atlas_mask(&device);
3550        let atlas_color = init_atlas_color(&device);
3551        let atlas_mask_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3552            label: Some("atlas mask bind"),
3553            layout: &text_bind_layout,
3554            entries: &[
3555                wgpu::BindGroupEntry {
3556                    binding: 0,
3557                    resource: wgpu::BindingResource::TextureView(&atlas_mask.view),
3558                },
3559                wgpu::BindGroupEntry {
3560                    binding: 1,
3561                    resource: wgpu::BindingResource::Sampler(&atlas_mask.sampler),
3562                },
3563            ],
3564        });
3565        let atlas_color_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
3566            label: Some("atlas color bind"),
3567            layout: &text_bind_layout,
3568            entries: &[
3569                wgpu::BindGroupEntry {
3570                    binding: 0,
3571                    resource: wgpu::BindingResource::TextureView(&atlas_color.view),
3572                },
3573                wgpu::BindGroupEntry {
3574                    binding: 1,
3575                    resource: wgpu::BindingResource::Sampler(&atlas_color.sampler),
3576                },
3577            ],
3578        });
3579
3580        // Upload rings
3581        let ring_rect = UploadRing::new(
3582            &device,
3583            "ring rect",
3584            1 << 20,
3585            wgpu::BufferUsages::VERTEX
3586                | wgpu::BufferUsages::COPY_DST
3587                | wgpu::BufferUsages::COPY_SRC,
3588        );
3589        let ring_border = UploadRing::new(
3590            &device,
3591            "ring border",
3592            1 << 20,
3593            wgpu::BufferUsages::VERTEX
3594                | wgpu::BufferUsages::COPY_DST
3595                | wgpu::BufferUsages::COPY_SRC,
3596        );
3597        let ring_ellipse = UploadRing::new(
3598            &device,
3599            "ring ellipse",
3600            1 << 20,
3601            wgpu::BufferUsages::VERTEX
3602                | wgpu::BufferUsages::COPY_DST
3603                | wgpu::BufferUsages::COPY_SRC,
3604        );
3605        let ring_ellipse_border = UploadRing::new(
3606            &device,
3607            "ring ellipse border",
3608            1 << 20,
3609            wgpu::BufferUsages::VERTEX
3610                | wgpu::BufferUsages::COPY_DST
3611                | wgpu::BufferUsages::COPY_SRC,
3612        );
3613        let ring_arc = UploadRing::new(
3614            &device,
3615            "ring arc",
3616            1 << 20,
3617            wgpu::BufferUsages::VERTEX
3618                | wgpu::BufferUsages::COPY_DST
3619                | wgpu::BufferUsages::COPY_SRC,
3620        );
3621        let ring_glyph_mask = UploadRing::new(
3622            &device,
3623            "ring glyph mask",
3624            1 << 20,
3625            wgpu::BufferUsages::VERTEX
3626                | wgpu::BufferUsages::COPY_DST
3627                | wgpu::BufferUsages::COPY_SRC,
3628        );
3629        let ring_glyph_color = UploadRing::new(
3630            &device,
3631            "ring glyph color",
3632            1 << 20,
3633            wgpu::BufferUsages::VERTEX
3634                | wgpu::BufferUsages::COPY_DST
3635                | wgpu::BufferUsages::COPY_SRC,
3636        );
3637        let ring_slug = UploadRing::new(
3638            &device,
3639            "ring slug",
3640            1 << 22,
3641            wgpu::BufferUsages::VERTEX
3642                | wgpu::BufferUsages::COPY_DST
3643                | wgpu::BufferUsages::COPY_SRC,
3644        );
3645        let ring_clip = UploadRing::new(
3646            &device,
3647            "ring clip",
3648            1 << 16,
3649            wgpu::BufferUsages::VERTEX
3650                | wgpu::BufferUsages::COPY_DST
3651                | wgpu::BufferUsages::COPY_SRC,
3652        );
3653        let blend_ring = UploadRing::new(
3654            &device,
3655            "ring blend",
3656            1 << 16,
3657            wgpu::BufferUsages::VERTEX
3658                | wgpu::BufferUsages::COPY_DST
3659                | wgpu::BufferUsages::COPY_SRC,
3660        );
3661        let ring_projective = UploadRing::new(
3662            &device,
3663            "ring projective",
3664            1 << 16,
3665            wgpu::BufferUsages::VERTEX
3666                | wgpu::BufferUsages::COPY_DST
3667                | wgpu::BufferUsages::COPY_SRC,
3668        );
3669        let ring_nv12 = UploadRing::new(
3670            &device,
3671            "ring nv12",
3672            1 << 20,
3673            wgpu::BufferUsages::VERTEX
3674                | wgpu::BufferUsages::COPY_DST
3675                | wgpu::BufferUsages::COPY_SRC,
3676        );
3677        // Placeholder textures
3678        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
3679            label: Some("temp ds"),
3680            size: wgpu::Extent3d {
3681                width: 1,
3682                height: 1,
3683                depth_or_array_layers: 1,
3684            },
3685            mip_level_count: 1,
3686            sample_count: 1,
3687            dimension: wgpu::TextureDimension::D2,
3688            format: wgpu::TextureFormat::Depth24PlusStencil8,
3689            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
3690            view_formats: &[],
3691        });
3692        let depth_stencil_view =
3693            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
3694
3695        let mut renderer = WgpuSceneRenderer {
3696            device,
3697            queue,
3698            output_format,
3699            surface_format: output_format,
3700            output_width: 0,
3701            output_height: 0,
3702            pixels_per_point: 1.0,
3703
3704            surface_pipes,
3705            working_space_pipes,
3706            layer_pipes,
3707            working_space_layer_pipes,
3708
3709            rects: InstancedPipe::new(ring_rect),
3710            borders: InstancedPipe::new(ring_border),
3711            ellipses: InstancedPipe::new(ring_ellipse),
3712            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
3713            arcs: InstancedPipe::new(ring_arc),
3714            glyph_mask: InstancedPipe::new(ring_glyph_mask),
3715            glyph_color: InstancedPipe::new(ring_glyph_color),
3716
3717            text_bind_layout,
3718
3719            image_bind_layout_rgba,
3720            image_bind_layout_nv12,
3721            image_sampler,
3722            image_sampler_nearest,
3723            image_sampler_repeat,
3724            image_sampler_repeat_nearest,
3725            layer_sampler,
3726            layer_sampler_linear,
3727
3728            blur_ring,
3729
3730            slug_enabled,
3731            slug_ring: ring_slug,
3732            slug_cache: slug::GlyphSlugCache::new(),
3733            slug_draw_cache: HashMap::new(),
3734            slug_draw_cache_bytes_total: 0,
3735
3736            clip_ring: ring_clip,
3737
3738            nv12: InstancedPipe::new(ring_nv12),
3739
3740            mesh_cache: HashMap::new(),
3741            mesh_cache_bytes_total: 0,
3742            mesh_uniform_buf,
3743            mesh_bind,
3744            mesh_uniform_head: 0,
3745            mesh_uniform_alignment,
3746            mesh_uniform_cap,
3747            mesh_instance_buf,
3748            mesh_instance_head: 0,
3749            mesh_instance_cap,
3750
3751            projective_ring: ring_projective,
3752            blend_ring,
3753            flatten_layer_ids: Vec::new(),
3754            blur_scratch_ids: HashMap::new(),
3755            blur_scratch_next: 0,
3756            blend_snapshots: std::collections::HashMap::new(),
3757            blend_snapshot_pool: HashMap::new(),
3758            blend_copies: Vec::new(),
3759
3760            msaa_samples,
3761            working_space_msaa_samples,
3762            depth_stencil_tex,
3763            depth_stencil_view,
3764            msaa_tex: None,
3765            msaa_view: None,
3766            ws_msaa_tex: None,
3767            ws_msaa_view: None,
3768            surface_resolve_tex: None,
3769            surface_resolve_view: None,
3770            surface_resolve_bytes: 0,
3771            msaa_bytes: 0,
3772            ws_msaa_bytes: 0,
3773            depth_stencil_bytes: 0,
3774            working_space_bytes: 0,
3775            gpu_budget_bytes: MAX_GPU_RESOURCE_BYTES,
3776            globals_bind,
3777            globals_buf,
3778            globals_staging,
3779            globals_staging_cap,
3780
3781            atlas_mask,
3782            atlas_color,
3783            atlas_mask_bind,
3784            atlas_color_bind,
3785            atlas_generation: repose_text::font_generation(),
3786            atlas_mask_pending: AtlasPending::default(),
3787            atlas_color_pending: AtlasPending::default(),
3788            atlas_last_compact_frame: 0,
3789            atlas_mask_failures: HashSet::new(),
3790            atlas_color_failures: HashSet::new(),
3791            atlas_mask_deferred: HashSet::new(),
3792            atlas_color_deferred: HashSet::new(),
3793            glyph_outline_cache: HashMap::new(),
3794            legacy_shaped_text_cache: HashMap::new(),
3795
3796            next_image_handle: 1,
3797            images: HashMap::new(),
3798            retained: HashMap::new(),
3799            retained_bytes_total: 0,
3800
3801            next_coverage_handle: 1,
3802            coverages: HashMap::new(),
3803
3804            frame_index: 0,
3805            cache_touch_clock: 0,
3806            image_bytes_total: 0,
3807            image_evict_after_frames: 600,         // ~10s @ 60fps
3808            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
3809            layer_pool: HashMap::new(),
3810            producer_layer_ids: HashSet::new(),
3811            layer_bytes_total: 0,
3812            transient_layer_bytes_total: 0,
3813
3814            working_space: false,
3815            ws_tex: None,
3816            ws_view: None,
3817            ws_bind: None,
3818            display_pipeline: None,
3819            display_layout: None,
3820
3821            callback_resources: CallbackResources::default(),
3822            callback_scoped_resources: HashMap::new(),
3823            callback_scope_uses: HashMap::new(),
3824            callback_scope_payloads: HashMap::new(),
3825            callback_scope_clock: 0,
3826            blend_snapshot_bytes_total: 0,
3827            frame_active: false,
3828            last_render_error: None,
3829        };
3830
3831        renderer.recreate_msaa_and_depth_stencil();
3832        Ok(renderer)
3833    }
3834}
3835
3836/// wgpu backend names accepted by [`parse_backends`], mapped from the
3837/// canonical [`wgpu::Backend::to_str`] spelling plus the aliases people
3838/// actually type.
3839#[cfg(not(target_arch = "wasm32"))]
3840fn backend_by_name(name: &str) -> Option<wgpu::Backend> {
3841    match name {
3842        "gl" | "opengl" | "opengles" | "webgl" => Some(wgpu::Backend::Gl),
3843        "webgpu" | "browser-webgpu" => Some(wgpu::Backend::BrowserWebGpu),
3844        "metal" => Some(wgpu::Backend::Metal),
3845        "vulkan" => Some(wgpu::Backend::Vulkan),
3846        "dx12" | "d3d12" => Some(wgpu::Backend::Dx12),
3847        _ => None,
3848    }
3849}
3850
3851/// Parse a comma/space separated wgpu backend list such as `gl` or
3852/// `vulkan,gl`. Unknown names are dropped with a warning rather than
3853/// failing the whole spec, and `None` means "nothing valid was requested".
3854#[cfg(not(target_arch = "wasm32"))]
3855pub fn parse_backends(spec: &str) -> Option<wgpu::Backends> {
3856    let mut bits = wgpu::Backends::empty();
3857    for name in spec.split([',', ' ', '+']).filter(|n| !n.is_empty()) {
3858        match backend_by_name(&name.to_ascii_lowercase()) {
3859            Some(backend) => bits |= wgpu::Backends::from(backend),
3860            None => log::warn!(
3861                "REPOSE_WGPU_BACKENDS: ignoring unknown backend {name:?}; \
3862                 expected one of noop, vulkan, metal, dx12, gl, webgpu"
3863            ),
3864        }
3865    }
3866    (!bits.is_empty()).then_some(bits)
3867}
3868
3869#[cfg(feature = "winit-surface")]
3870pub(crate) async fn surface_instance(
3871    display: Option<winit::event_loop::OwnedDisplayHandle>,
3872) -> (Instance, wgpu::Backends) {
3873    // The display handle is what lets wgpu's GLES backend reach a real
3874    // platform surface. Without it wgpu falls back to a surfaceless/legacy-EGL
3875    // display, and on Wayland the GL adapter is then rejected outright as
3876    // surface-incompatible, which kills the OpenGL fallback entirely.
3877    let mut desc = match display {
3878        Some(handle) => wgpu::InstanceDescriptor::new_with_display_handle(Box::new(handle)),
3879        None => wgpu::InstanceDescriptor::new_without_display_handle(),
3880    };
3881    if cfg!(target_arch = "wasm32") {
3882        desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
3883    }
3884    #[cfg(not(target_arch = "wasm32"))]
3885    if let Ok(spec) = std::env::var("REPOSE_WGPU_BACKENDS")
3886        && let Some(bits) = parse_backends(&spec)
3887    {
3888        log::info!("REPOSE_WGPU_BACKENDS={spec} -> {bits:?}");
3889        desc.backends = bits;
3890    }
3891
3892    let backends = desc.backends;
3893    let instance = if cfg!(target_arch = "wasm32") {
3894        wgpu::util::new_instance_with_webgpu_detection(desc).await
3895    } else {
3896        wgpu::Instance::new(desc)
3897    };
3898    (instance, backends)
3899}
3900
3901impl WgpuSurfaceBackend {
3902    #[cfg(feature = "winit-surface")]
3903    pub async fn new_async(
3904        window: Arc<winit::window::Window>,
3905    ) -> anyhow::Result<WgpuSurfaceBackend> {
3906        Self::new_async_with_options(window, None, 4, PresentModePref::Auto).await
3907    }
3908
3909    /// Create a windowed surface backend, honoring the requested MSAA sample
3910    /// count (falling back to the largest supported count <= `msaa_samples`).
3911    #[cfg(feature = "winit-surface")]
3912    pub async fn new_async_with_msaa(
3913        window: Arc<winit::window::Window>,
3914        msaa_samples: u32,
3915    ) -> anyhow::Result<WgpuSurfaceBackend> {
3916        Self::new_async_with_options(window, None, msaa_samples, PresentModePref::Auto).await
3917    }
3918
3919    /// Create a windowed surface backend, honoring the requested MSAA sample
3920    /// count and present-mode preference.
3921    ///
3922    /// `display` must be the event loop's owned display handle on native
3923    /// platforms, otherwise the GL backend cannot present (see
3924    /// [`surface_instance`]).
3925    #[cfg(feature = "winit-surface")]
3926    pub async fn new_async_with_options(
3927        window: Arc<winit::window::Window>,
3928        display: Option<winit::event_loop::OwnedDisplayHandle>,
3929        msaa_samples: u32,
3930        present_mode: PresentModePref,
3931    ) -> anyhow::Result<WgpuSurfaceBackend> {
3932        let (instance, backends) = surface_instance(display).await;
3933
3934        let surface = instance.create_surface(window.clone())?;
3935
3936        let adapter = instance
3937            .request_adapter(&wgpu::RequestAdapterOptions {
3938                power_preference: wgpu::PowerPreference::HighPerformance,
3939                compatible_surface: Some(&surface),
3940                force_fallback_adapter: false,
3941                apply_limit_buckets: false,
3942            })
3943            .await
3944            .map_err(|e| {
3945                anyhow::anyhow!("no adapter for this window (enabled backends {backends:?}): {e:?}")
3946            })?;
3947        log::info!(
3948            "wgpu adapter: {} backend={:?} driver={:?} type={:?}",
3949            adapter.get_info().name,
3950            adapter.get_info().backend,
3951            adapter.get_info().driver,
3952            adapter.get_info().device_type,
3953        );
3954
3955        let limits = adapter.limits();
3956
3957        let features = {
3958            let available = adapter.features();
3959            let mut features = wgpu::Features::empty();
3960            if available.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) {
3961                features |= wgpu::Features::TEXTURE_FORMAT_16BIT_NORM;
3962            }
3963            #[cfg(target_os = "linux")]
3964            {
3965                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
3966                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
3967                }
3968                if available.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
3969                    features |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
3970                }
3971            }
3972            features
3973        };
3974
3975        let (device, queue) = adapter
3976            .request_device(&wgpu::DeviceDescriptor {
3977                label: Some("repose-rs device"),
3978                required_features: features,
3979                required_limits: limits,
3980                experimental_features: wgpu::ExperimentalFeatures::disabled(),
3981                memory_hints: wgpu::MemoryHints::default(),
3982                trace: wgpu::Trace::Off,
3983            })
3984            .await
3985            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
3986
3987        let device_lost = Arc::new(Mutex::new(None));
3988        {
3989            let device_lost = device_lost.clone();
3990            device.set_device_lost_callback(move |reason, message| {
3991                log::error!("wgpu device lost ({reason:?}): {message}");
3992                if let Ok(mut slot) = device_lost.lock() {
3993                    *slot = Some((reason, message));
3994                }
3995            });
3996        }
3997
3998        let size = window.inner_size();
3999
4000        let caps = surface.get_capabilities(&adapter);
4001
4002        let Some(format) = caps.formats.first().copied() else {
4003            anyhow::bail!(
4004                "surface exposes no presentable formats (adapter {})",
4005                adapter.get_info().name
4006            );
4007        };
4008
4009        let (format, view_format) = if cfg!(target_arch = "wasm32")
4010            && adapter
4011                .get_downlevel_capabilities()
4012                .flags
4013                .contains(wgpu::DownlevelFlags::SURFACE_VIEW_FORMATS)
4014        {
4015            let non_srgb = caps
4016                .formats
4017                .iter()
4018                .copied()
4019                .find(|f| !f.is_srgb())
4020                .unwrap_or(format);
4021            (non_srgb, Some(non_srgb.add_srgb_suffix()))
4022        } else if cfg!(target_arch = "wasm32") {
4023            let fmt = caps
4024                .formats
4025                .iter()
4026                .copied()
4027                .find(|f| f.is_srgb())
4028                .unwrap_or(format);
4029            (fmt, None)
4030        } else {
4031            let fmt = caps
4032                .formats
4033                .iter()
4034                .copied()
4035                .find(|f| f.is_srgb())
4036                .unwrap_or(format);
4037            (fmt, None)
4038        };
4039
4040        let present_mode = pick_present_mode(&caps, present_mode);
4041        let alpha_mode = *caps
4042            .alpha_modes
4043            .first()
4044            .ok_or_else(|| anyhow::anyhow!("surface exposes no alpha modes"))?;
4045
4046        let render_format = view_format.unwrap_or(format);
4047        let msaa_samples = pick_surface_msaa(&adapter, render_format, msaa_samples);
4048        let mut renderer = WgpuSceneRenderer::try_from_device_with_adapter(
4049            &adapter,
4050            device,
4051            queue,
4052            render_format,
4053            msaa_samples,
4054        )?
4055        .with_surface_format(format)?;
4056        let (fw, fh) = clamp_output_size(
4057            size.width,
4058            size.height,
4059            renderer.device.limits().max_texture_dimension_2d,
4060        );
4061        renderer.resize(fw, fh);
4062
4063        let view_formats = view_format.into_iter().collect::<Vec<_>>();
4064
4065        // `COPY_SRC` lets an isolated blend snapshot the backdrop straight from
4066        // the swapchain when there is no multisample resolve target to copy
4067        // from. Canvas textures may not be storage-bound, but copying from them
4068        // is allowed. Not every backend grants it - GL surfaces are
4069        // render-target only - and the snapshot path already skips a source
4070        // without `COPY_SRC`, so request the intersection rather than making
4071        // `configure` fail and taking down every backend that lacks the usage.
4072        let wanted_usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
4073        let surface_usage = wanted_usage & caps.usages;
4074        if surface_usage != wanted_usage {
4075            log::warn!(
4076                "surface {format:?} cannot be used as {:?}; swapchain-sourced blend snapshots \
4077                 are skipped on this backend",
4078                wanted_usage - surface_usage
4079            );
4080        }
4081        let config = wgpu::SurfaceConfiguration {
4082            usage: surface_usage,
4083            format,
4084            width: fw.max(1),
4085            height: fh.max(1),
4086            present_mode,
4087            alpha_mode,
4088            color_space: wgpu::SurfaceColorSpace::Auto,
4089            view_formats,
4090            desired_maximum_frame_latency: 1,
4091        };
4092        surface.configure(&renderer.device, &config);
4093
4094        Ok(WgpuSurfaceBackend {
4095            #[cfg(feature = "winit-surface")]
4096            instance: Some(instance),
4097            #[cfg(feature = "winit-surface")]
4098            window: Some(window),
4099            surface: Some(surface),
4100            surface_config: Some(config),
4101            pending_reconfigure: false,
4102            renderer,
4103            device_lost,
4104        })
4105    }
4106
4107    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4108    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4109        pollster::block_on(Self::new_async(window))
4110    }
4111
4112    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4113    pub fn new_with_msaa(
4114        window: Arc<winit::window::Window>,
4115        msaa_samples: u32,
4116    ) -> anyhow::Result<WgpuSurfaceBackend> {
4117        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
4118    }
4119
4120    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
4121    pub fn new_with_options(
4122        window: Arc<winit::window::Window>,
4123        display: Option<winit::event_loop::OwnedDisplayHandle>,
4124        msaa_samples: u32,
4125        present_mode: PresentModePref,
4126    ) -> anyhow::Result<WgpuSurfaceBackend> {
4127        pollster::block_on(Self::new_async_with_options(
4128            window,
4129            display,
4130            msaa_samples,
4131            present_mode,
4132        ))
4133    }
4134
4135    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4136    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
4137        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
4138    }
4139
4140    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4141    pub fn new_with_msaa(
4142        _window: Arc<winit::window::Window>,
4143        _msaa_samples: u32,
4144    ) -> anyhow::Result<WgpuSurfaceBackend> {
4145        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
4146    }
4147
4148    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
4149    pub fn new_with_options(
4150        _window: Arc<winit::window::Window>,
4151        _display: Option<winit::event_loop::OwnedDisplayHandle>,
4152        _msaa_samples: u32,
4153        _present_mode: PresentModePref,
4154    ) -> anyhow::Result<WgpuSurfaceBackend> {
4155        anyhow::bail!(
4156            "Use WgpuSurfaceBackend::new_async_with_options(window, display, msaa, mode).await on wasm32"
4157        )
4158    }
4159
4160    /// Reason the device was lost, consumed once. `None` while healthy and
4161    /// after an ordinary `Destroyed`, which is what dropping this backend
4162    /// produces; runners use this to tell a crash/reset apart from teardown
4163    /// and rebuild the backend.
4164    pub fn take_device_lost(&self) -> Option<String> {
4165        let (reason, message) = self.device_lost.lock().ok()?.take()?;
4166        (reason != wgpu::DeviceLostReason::Destroyed).then_some(message)
4167    }
4168}
4169
4170/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
4171/// Fifo-first selection when the preferred mode is unavailable.
4172#[cfg(feature = "winit-surface")]
4173fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
4174    let auto = || {
4175        caps.present_modes
4176            .iter()
4177            .copied()
4178            .find(|m| *m == wgpu::PresentMode::Fifo)
4179            .or_else(|| {
4180                caps.present_modes
4181                    .iter()
4182                    .copied()
4183                    .find(|m| *m == wgpu::PresentMode::Mailbox)
4184            })
4185            .unwrap_or(wgpu::PresentMode::Immediate)
4186    };
4187    match pref {
4188        PresentModePref::Auto => auto(),
4189        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
4190            wgpu::PresentMode::Fifo
4191        }
4192        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
4193            wgpu::PresentMode::Mailbox
4194        }
4195        PresentModePref::Immediate
4196            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
4197        {
4198            wgpu::PresentMode::Immediate
4199        }
4200        _ => auto(),
4201    }
4202}
4203
4204/// `wgpu` only accepts these sample counts; anything else (3, 5, 17, 0) is
4205/// a hard validation error at pipeline creation.
4206const VALID_SAMPLE_COUNTS: [u32; 5] = [1, 2, 4, 8, 16];
4207
4208fn validate_sample_count(label: &str, samples: u32) -> anyhow::Result<()> {
4209    if !VALID_SAMPLE_COUNTS.contains(&samples) {
4210        anyhow::bail!(
4211            "{label} must be one of {VALID_SAMPLE_COUNTS:?} (wgpu-supported sample counts), got {samples}"
4212        );
4213    }
4214    Ok(())
4215}
4216
4217/// Every UI pipeline writes `output_format` with premultiplied-alpha
4218/// blending, so those two properties have to hold before any pipeline is
4219/// built. Multisample resolve is only required when more than one sample is
4220/// actually in use: a format that is renderable and blendable but cannot
4221/// resolve is fine at x1, which is what [`pick_surface_msaa`] falls back to.
4222fn validate_render_target_format(
4223    adapter: &wgpu::Adapter,
4224    output_format: wgpu::TextureFormat,
4225    samples: u32,
4226) -> anyhow::Result<()> {
4227    let features = adapter.get_texture_format_features(output_format);
4228    let flags = features.flags;
4229    for (needed, what) in [
4230        (wgpu::TextureUsages::RENDER_ATTACHMENT, "render attachment"),
4231        (wgpu::TextureUsages::TEXTURE_BINDING, "texture binding"),
4232    ] {
4233        if !features.allowed_usages.contains(needed) {
4234            anyhow::bail!("{output_format:?} cannot be used as a {what}");
4235        }
4236    }
4237    if !flags.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) {
4238        anyhow::bail!("{output_format:?} cannot be blended into");
4239    }
4240    if samples > 1 && !flags.contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE) {
4241        anyhow::bail!(
4242            "{output_format:?} cannot resolve the requested {samples}x multisampled attachment"
4243        );
4244    }
4245    // `set_working_space` renders into Rgba16Float and resolves into it.
4246    let ws = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4247    if !ws
4248        .allowed_usages
4249        .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4250        || !ws
4251            .allowed_usages
4252            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4253    {
4254        anyhow::bail!(
4255            "{:?} cannot be used for linear working-space rendering",
4256            wgpu::TextureFormat::Rgba16Float
4257        );
4258    }
4259    Ok(())
4260}
4261
4262/// Pick the MSAA sample count for the surface pass, honoring `requested` and
4263/// falling back to the largest supported count <= it.
4264pub fn pick_surface_msaa(
4265    adapter: &wgpu::Adapter,
4266    format: wgpu::TextureFormat,
4267    requested: u32,
4268) -> u32 {
4269    pick_surface_msaa_for_mode(adapter, format, requested, false)
4270}
4271
4272pub fn pick_surface_msaa_for_mode(
4273    adapter: &wgpu::Adapter,
4274    format: wgpu::TextureFormat,
4275    requested: u32,
4276    working_space: bool,
4277) -> u32 {
4278    let requested = requested.max(1);
4279    let color_feat = adapter.get_texture_format_features(format);
4280    let working_space_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba16Float);
4281    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
4282    let supported = |n: u32| {
4283        color_feat
4284            .allowed_usages
4285            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4286            && color_feat.flags.sample_count_supported(n)
4287            && (!working_space
4288                || (working_space_feat
4289                    .allowed_usages
4290                    .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4291                    && working_space_feat.flags.sample_count_supported(n)
4292                    && (n == 1
4293                        || working_space_feat
4294                            .flags
4295                            .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))))
4296            && (n == 1
4297                || color_feat
4298                    .flags
4299                    .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE))
4300            && depth_feat
4301                .allowed_usages
4302                .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
4303            && depth_feat.flags.sample_count_supported(n)
4304    };
4305    let mut candidates = vec![requested];
4306    for n in [8, 4, 2, 1] {
4307        if n < requested {
4308            candidates.push(n);
4309        }
4310    }
4311    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
4312    if chosen != requested {
4313        log::info!("requested MSAA x{requested}, using x{chosen}");
4314    }
4315    chosen
4316}
4317
4318impl WgpuSceneRenderer {
4319    // Image API
4320
4321    pub fn set_image_from_bytes(
4322        &mut self,
4323        handle: u64,
4324        data: &[u8],
4325        srgb: bool,
4326    ) -> anyhow::Result<()> {
4327        validate_image_handle(handle)?;
4328        let max_dimension = self.device.limits().max_texture_dimension_2d;
4329        let (w, h, rgba) = decode_image_rgba8(data, max_dimension)?;
4330        self.set_image_rgba8(handle, w, h, &rgba, srgb)?;
4331        // Encoded uploads have no size at the call site, so publish it here for
4332        // layout - only once the pixels are actually on the GPU.
4333        repose_core::set_image_intrinsic_size(handle, w, h);
4334        Ok(())
4335    }
4336
4337    pub fn set_image_rgba8(
4338        &mut self,
4339        handle: u64,
4340        w: u32,
4341        h: u32,
4342        rgba: &[u8],
4343        srgb: bool,
4344    ) -> anyhow::Result<()> {
4345        validate_image_handle(handle)?;
4346        validate_texture_dimensions(&self.device, w, h)?;
4347        let expected = checked_image_bytes_usize(w, h, 4)?;
4348        if rgba.len() < expected {
4349            return Err(anyhow::anyhow!(
4350                "RGBA buffer too small: {} < {}",
4351                rgba.len(),
4352                expected
4353            ));
4354        }
4355
4356        let format = if srgb {
4357            wgpu::TextureFormat::Rgba8UnormSrgb
4358        } else {
4359            wgpu::TextureFormat::Rgba8Unorm
4360        };
4361
4362        let needs_recreate = match self.images.get(&handle) {
4363            Some(ImageTex::Rgba {
4364                w: cw,
4365                h: ch,
4366                format: cf,
4367                ..
4368            }) => *cw != w || *ch != h || *cf != format,
4369            _ => true,
4370        };
4371
4372        if needs_recreate {
4373            self.remove_image(handle);
4374
4375            let (tex, binds) = self.create_rgba_tex(w, h, format);
4376            let bytes = checked_image_bytes(w, h, 4)?;
4377            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4378
4379            self.images.insert(
4380                handle,
4381                ImageTex::Rgba {
4382                    tex,
4383                    binds,
4384                    w,
4385                    h,
4386                    format,
4387                    last_used_frame: self.frame_index,
4388                    bytes,
4389                },
4390            );
4391        }
4392
4393        if let Some(old) = self.retained.remove(&handle) {
4394            self.retained_bytes_total = self
4395                .retained_bytes_total
4396                .saturating_sub(old.rgba.len() as u64);
4397        }
4398        self.retained_bytes_total = self.retained_bytes_total.saturating_add(expected as u64);
4399        self.retained.insert(
4400            handle,
4401            RetainedImage {
4402                w,
4403                h,
4404                format,
4405                rgba: rgba[..expected].to_vec(),
4406                last_used_frame: self.frame_index,
4407            },
4408        );
4409        self.evict_retained_excess();
4410
4411        let tex = match self.images.get(&handle) {
4412            Some(ImageTex::Rgba { tex, .. }) => tex,
4413            _ => unreachable!(),
4414        };
4415
4416        self.queue.write_texture(
4417            wgpu::TexelCopyTextureInfo {
4418                texture: tex,
4419                mip_level: 0,
4420                origin: wgpu::Origin3d::ZERO,
4421                aspect: wgpu::TextureAspect::All,
4422            },
4423            &rgba[..expected],
4424            wgpu::TexelCopyBufferLayout {
4425                offset: 0,
4426                bytes_per_row: Some(4 * w),
4427                rows_per_image: Some(h),
4428            },
4429            wgpu::Extent3d {
4430                width: w,
4431                height: h,
4432                depth_or_array_layers: 1,
4433            },
4434        );
4435
4436        // Ensure budget limits
4437        self.evict_budget_excess();
4438
4439        Ok(())
4440    }
4441
4442    /// Create (but do not populate) the GPU texture, view and bind group for an
4443    /// RGBA image. Pixels are written separately via `write_texture`.
4444    fn create_image_binds(
4445        &self,
4446        view: &wgpu::TextureView,
4447        label: &str,
4448        sampler: Option<&wgpu::Sampler>,
4449    ) -> ImageBinds {
4450        let make = |name: &str, sampler: &wgpu::Sampler| {
4451            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4452                label: Some(name),
4453                layout: &self.image_bind_layout_rgba,
4454                entries: &[
4455                    wgpu::BindGroupEntry {
4456                        binding: 0,
4457                        resource: wgpu::BindingResource::TextureView(view),
4458                    },
4459                    wgpu::BindGroupEntry {
4460                        binding: 1,
4461                        resource: wgpu::BindingResource::Sampler(sampler),
4462                    },
4463                ],
4464            })
4465        };
4466        match sampler {
4467            Some(sampler) => {
4468                let bind = make(label, sampler);
4469                ImageBinds {
4470                    linear: make(label, sampler),
4471                    nearest: bind.clone(),
4472                    tile_linear: bind.clone(),
4473                    tile_nearest: bind,
4474                }
4475            }
4476            None => ImageBinds {
4477                linear: make(label, &self.image_sampler),
4478                nearest: make(label, &self.image_sampler_nearest),
4479                tile_linear: make(label, &self.image_sampler_repeat),
4480                tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4481            },
4482        }
4483    }
4484
4485    fn create_nv12_binds(
4486        &self,
4487        view_y: &wgpu::TextureView,
4488        view_uv: &wgpu::TextureView,
4489        yuv_buf: &wgpu::Buffer,
4490        label: &str,
4491    ) -> ImageBinds {
4492        let make = |name: &str, sampler: &wgpu::Sampler| {
4493            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4494                label: Some(name),
4495                layout: &self.image_bind_layout_nv12,
4496                entries: &[
4497                    wgpu::BindGroupEntry {
4498                        binding: 0,
4499                        resource: wgpu::BindingResource::TextureView(view_y),
4500                    },
4501                    wgpu::BindGroupEntry {
4502                        binding: 1,
4503                        resource: wgpu::BindingResource::TextureView(view_uv),
4504                    },
4505                    wgpu::BindGroupEntry {
4506                        binding: 2,
4507                        resource: wgpu::BindingResource::Sampler(sampler),
4508                    },
4509                    wgpu::BindGroupEntry {
4510                        binding: 3,
4511                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4512                            buffer: yuv_buf,
4513                            offset: 0,
4514                            size: None,
4515                        }),
4516                    },
4517                ],
4518            })
4519        };
4520        ImageBinds {
4521            linear: make(label, &self.image_sampler),
4522            nearest: make(label, &self.image_sampler_nearest),
4523            tile_linear: make(label, &self.image_sampler_repeat),
4524            tile_nearest: make(label, &self.image_sampler_repeat_nearest),
4525        }
4526    }
4527
4528    fn create_rgba_tex(
4529        &self,
4530        w: u32,
4531        h: u32,
4532        format: wgpu::TextureFormat,
4533    ) -> (wgpu::Texture, ImageBinds) {
4534        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
4535            label: Some("user image rgba"),
4536            size: wgpu::Extent3d {
4537                width: w,
4538                height: h,
4539                depth_or_array_layers: 1,
4540            },
4541            mip_level_count: 1,
4542            sample_count: 1,
4543            dimension: wgpu::TextureDimension::D2,
4544            format,
4545            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4546            view_formats: &[],
4547        });
4548        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
4549        let binds = self.create_image_binds(&view, "image bind rgba", None);
4550        (tex, binds)
4551    }
4552
4553    /// Register an externally-created `wgpu::TextureView` as an image (zero-copy).
4554    fn validate_native_texture_view(
4555        &self,
4556        view: &wgpu::TextureView,
4557        width: u32,
4558        height: u32,
4559    ) -> anyhow::Result<()> {
4560        if width == 0 || height == 0 {
4561            anyhow::bail!("native texture dimensions must be non-zero");
4562        }
4563        let texture = view.texture();
4564        if texture.sample_count() != 1
4565            || texture.dimension() != wgpu::TextureDimension::D2
4566            || texture.depth_or_array_layers() != 1
4567            || texture.mip_level_count() == 0
4568        {
4569            anyhow::bail!("native texture must be a single-sample 2D texture with mip levels");
4570        }
4571        if width > self.device.limits().max_texture_dimension_2d
4572            || height > self.device.limits().max_texture_dimension_2d
4573        {
4574            anyhow::bail!("native texture dimensions exceed the device limit");
4575        }
4576        if !texture
4577            .usage()
4578            .contains(wgpu::TextureUsages::TEXTURE_BINDING)
4579        {
4580            anyhow::bail!("native texture lacks TEXTURE_BINDING usage");
4581        }
4582        if !native_format_supported(texture.format(), self.device.features()) {
4583            anyhow::bail!(
4584                "native texture format {:?} is not a filterable alpha color format",
4585                texture.format()
4586            );
4587        }
4588        if texture.width() < width || texture.height() < height {
4589            anyhow::bail!(
4590                "native texture is {}x{}, smaller than requested {width}x{height}",
4591                texture.width(),
4592                texture.height()
4593            );
4594        }
4595        Ok(())
4596    }
4597
4598    pub fn register_native_texture(
4599        &mut self,
4600        view: &wgpu::TextureView,
4601        width: u32,
4602        height: u32,
4603    ) -> u64 {
4604        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4605            log::warn!("register_native_texture: {error:#}");
4606            return 0;
4607        }
4608        let handle = self.next_image_handle;
4609        self.next_image_handle += 1;
4610        let binds = self.create_image_binds(view, "user native image", None);
4611        self.images.insert(
4612            handle,
4613            ImageTex::User {
4614                binds,
4615                w: width,
4616                h: height,
4617                last_used_frame: self.frame_index,
4618                bytes: 0,
4619            },
4620        );
4621        handle
4622    }
4623
4624    /// Like `register_native_texture` but with custom sampler descriptor.
4625    pub fn register_native_texture_with_sampler(
4626        &mut self,
4627        view: &wgpu::TextureView,
4628        sampler_desc: wgpu::SamplerDescriptor<'_>,
4629        width: u32,
4630        height: u32,
4631    ) -> u64 {
4632        if let Err(error) = self.validate_native_texture_view(view, width, height) {
4633            log::warn!("register_native_texture: {error:#}");
4634            return 0;
4635        }
4636        if let Err(error) = validate_sampler_descriptor(&self.device, &sampler_desc) {
4637            log::warn!("register_native_texture: {error:#}");
4638            return 0;
4639        }
4640        let handle = self.next_image_handle;
4641        self.next_image_handle += 1;
4642        let sampler = self.device.create_sampler(&sampler_desc);
4643        let binds = self.create_image_binds(view, "user native image sampleropts", Some(&sampler));
4644        self.images.insert(
4645            handle,
4646            ImageTex::User {
4647                binds,
4648                w: width,
4649                h: height,
4650                last_used_frame: self.frame_index,
4651                bytes: 0,
4652            },
4653        );
4654        handle
4655    }
4656
4657    /// Update an existing native texture handle with a new view (reuse handle).
4658    pub fn update_native_texture(&mut self, handle: u64, view: &wgpu::TextureView) {
4659        if handle == 0 {
4660            log::warn!("update_native_texture: reserved handle");
4661            return;
4662        }
4663        let Some((w, h)) = self.images.get(&handle).and_then(|entry| match entry {
4664            ImageTex::User { w, h, .. } => Some((*w, *h)),
4665            _ => None,
4666        }) else {
4667            log::warn!("update_native_texture: handle {handle} is not a native image");
4668            return;
4669        };
4670        if let Err(error) = self.validate_native_texture_view(view, w, h) {
4671            log::warn!("update_native_texture: {error:#}");
4672            return;
4673        }
4674        let binds = self.create_image_binds(view, "user native image update", None);
4675        if let Some(entry) = self.images.get_mut(&handle) {
4676            *entry = ImageTex::User {
4677                binds,
4678                w,
4679                h,
4680                last_used_frame: self.frame_index,
4681                bytes: 0,
4682            };
4683        }
4684    }
4685
4686    pub fn set_image_nv12(
4687        &mut self,
4688        handle: u64,
4689        w: u32,
4690        h: u32,
4691        y: &[u8],
4692        uv: &[u8],
4693        color_info: ColorInfo,
4694    ) -> anyhow::Result<()> {
4695        validate_image_handle(handle)?;
4696        validate_texture_dimensions(&self.device, w, h)?;
4697        if self
4698            .images
4699            .get(&handle)
4700            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4701        {
4702            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4703        }
4704        let y_expected = checked_image_bytes_usize(w, h, 1)?;
4705        let uv_w = w.div_ceil(2);
4706        let uv_h = h.div_ceil(2);
4707        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 2)?;
4708
4709        if y.len() < y_expected {
4710            return Err(anyhow::anyhow!("Y plane too small"));
4711        }
4712        if uv.len() < uv_expected {
4713            return Err(anyhow::anyhow!("UV plane too small"));
4714        }
4715
4716        let needs_recreate = match self.images.get(&handle) {
4717            Some(ImageTex::Nv12 {
4718                w: ww,
4719                h: hh,
4720                pixel_format,
4721                ..
4722            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::Nv12,
4723            _ => true,
4724        };
4725
4726        // Compute the YUV->RGB transform on the CPU.
4727        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::Nv12);
4728
4729        if needs_recreate {
4730            self.remove_image(handle);
4731
4732            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4733                label: Some("nv12 Y"),
4734                size: wgpu::Extent3d {
4735                    width: w,
4736                    height: h,
4737                    depth_or_array_layers: 1,
4738                },
4739                mip_level_count: 1,
4740                sample_count: 1,
4741                dimension: wgpu::TextureDimension::D2,
4742                format: wgpu::TextureFormat::R8Unorm,
4743                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4744                view_formats: &[],
4745            });
4746            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4747
4748            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4749                label: Some("nv12 UV"),
4750                size: wgpu::Extent3d {
4751                    width: uv_w,
4752                    height: uv_h,
4753                    depth_or_array_layers: 1,
4754                },
4755                mip_level_count: 1,
4756                sample_count: 1,
4757                dimension: wgpu::TextureDimension::D2,
4758                format: wgpu::TextureFormat::Rg8Unorm,
4759                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4760                view_formats: &[],
4761            });
4762            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
4763
4764            // Create a uniform buffer for the YUV transform (per-image).
4765            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
4766                label: Some("nv12 yuv transform"),
4767                size: std::mem::size_of::<YuvTransformRaw>() as u64,
4768                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4769                mapped_at_creation: false,
4770            });
4771
4772            // Write initial transform.
4773            self.queue
4774                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4775
4776            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "nv12 bind");
4777
4778            let bytes = checked_image_bytes(w, h, 1)?
4779                .checked_add(checked_image_bytes(uv_w, uv_h, 2)?)
4780                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
4781                .ok_or_else(|| anyhow::anyhow!("NV12 image byte size overflow"))?;
4782            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
4783
4784            self.images.insert(
4785                handle,
4786                ImageTex::Nv12 {
4787                    tex_y,
4788                    tex_uv,
4789                    binds,
4790                    yuv_buf,
4791                    w,
4792                    h,
4793                    pixel_format: PixelFormat::Nv12,
4794                    fourcc: Some(NV12_FOURCC),
4795                    color_info,
4796                    external: false,
4797                    last_used_frame: self.frame_index,
4798                    bytes,
4799                },
4800            );
4801        } else {
4802            // Re-use existing textures; just update the YUV transform if needed.
4803            if let Some(ImageTex::Nv12 {
4804                yuv_buf,
4805                color_info: stored_color,
4806                last_used_frame,
4807                ..
4808            }) = self.images.get_mut(&handle)
4809            {
4810                *stored_color = color_info;
4811                *last_used_frame = self.frame_index;
4812                self.queue
4813                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
4814            }
4815        }
4816
4817        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
4818            Some(ImageTex::Nv12 {
4819                tex_y,
4820                tex_uv,
4821                binds,
4822                ..
4823            }) => (tex_y, tex_uv, binds),
4824            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
4825        };
4826
4827        self.queue.write_texture(
4828            wgpu::TexelCopyTextureInfo {
4829                texture: tex_y,
4830                mip_level: 0,
4831                origin: wgpu::Origin3d::ZERO,
4832                aspect: wgpu::TextureAspect::All,
4833            },
4834            &y[..y_expected],
4835            wgpu::TexelCopyBufferLayout {
4836                offset: 0,
4837                bytes_per_row: Some(w),
4838                rows_per_image: Some(h),
4839            },
4840            wgpu::Extent3d {
4841                width: w,
4842                height: h,
4843                depth_or_array_layers: 1,
4844            },
4845        );
4846
4847        self.queue.write_texture(
4848            wgpu::TexelCopyTextureInfo {
4849                texture: tex_uv,
4850                mip_level: 0,
4851                origin: wgpu::Origin3d::ZERO,
4852                aspect: wgpu::TextureAspect::All,
4853            },
4854            &uv[..uv_expected],
4855            wgpu::TexelCopyBufferLayout {
4856                offset: 0,
4857                bytes_per_row: Some(2 * uv_w),
4858                rows_per_image: Some(uv_h),
4859            },
4860            wgpu::Extent3d {
4861                width: uv_w,
4862                height: uv_h,
4863                depth_or_array_layers: 1,
4864            },
4865        );
4866
4867        self.evict_budget_excess();
4868        Ok(())
4869    }
4870
4871    pub fn set_image_planes(
4872        &mut self,
4873        handle: u64,
4874        w: u32,
4875        h: u32,
4876        pixel_format: PixelFormat,
4877        planes: &[&[u8]],
4878        color_info: ColorInfo,
4879    ) -> anyhow::Result<()> {
4880        validate_image_handle(handle)?;
4881        if planes.len() != pixel_format.num_planes() as usize {
4882            anyhow::bail!(
4883                "{} requires {} planes, got {}",
4884                match pixel_format {
4885                    PixelFormat::Nv12 => "NV12",
4886                    PixelFormat::P010 => "P010",
4887                    PixelFormat::I420 => "I420",
4888                    PixelFormat::I444 => "I444",
4889                    PixelFormat::Rgba => "RGBA",
4890                },
4891                pixel_format.num_planes(),
4892                planes.len()
4893            );
4894        }
4895        match pixel_format {
4896            PixelFormat::Nv12 => {
4897                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4898                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4899                self.set_image_nv12(handle, w, h, y, uv, color_info)
4900            }
4901            PixelFormat::P010 => {
4902                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
4903                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
4904                self.set_image_p010(handle, w, h, y, uv, color_info)
4905            }
4906            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
4907                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
4908            )),
4909            PixelFormat::Rgba => {
4910                let rgba = planes
4911                    .first()
4912                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
4913                self.set_image_rgba8(handle, w, h, rgba, false)
4914            }
4915        }
4916    }
4917
4918    fn set_image_p010(
4919        &mut self,
4920        handle: u64,
4921        w: u32,
4922        h: u32,
4923        y: &[u8],
4924        uv: &[u8],
4925        color_info: ColorInfo,
4926    ) -> anyhow::Result<()> {
4927        validate_image_handle(handle)?;
4928        validate_texture_dimensions(&self.device, w, h)?;
4929        if self
4930            .images
4931            .get(&handle)
4932            .is_some_and(|image| matches!(image, ImageTex::Nv12 { external: true, .. }))
4933        {
4934            anyhow::bail!("cannot overwrite an external DMA-BUF image");
4935        }
4936        if !self
4937            .device
4938            .features()
4939            .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
4940        {
4941            anyhow::bail!("P010 requires TEXTURE_FORMAT_16BIT_NORM");
4942        }
4943        let uv_w = w.div_ceil(2);
4944        let uv_h = h.div_ceil(2);
4945
4946        let y_expected = checked_image_bytes_usize(w, h, 2)?;
4947        let uv_expected = checked_image_bytes_usize(uv_w, uv_h, 4)?;
4948
4949        if y.len() < y_expected {
4950            return Err(anyhow::anyhow!("P010 Y plane too small"));
4951        }
4952        if uv.len() < uv_expected {
4953            return Err(anyhow::anyhow!("P010 UV plane too small"));
4954        }
4955
4956        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
4957        // abstracts the storage format so R16Unorm/Rg16Unorm are
4958        // filterable float textures just like R8Unorm/Rg8Unorm).
4959        let needs_recreate = match self.images.get(&handle) {
4960            Some(ImageTex::Nv12 {
4961                w: ww,
4962                h: hh,
4963                pixel_format,
4964                ..
4965            }) => *ww != w || *hh != h || *pixel_format != PixelFormat::P010,
4966            _ => true,
4967        };
4968
4969        let yuv_raw = make_yuv_transform_raw(color_info, PixelFormat::P010);
4970
4971        if needs_recreate {
4972            self.remove_image(handle);
4973
4974            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
4975                label: Some("p010 Y"),
4976                size: wgpu::Extent3d {
4977                    width: w,
4978                    height: h,
4979                    depth_or_array_layers: 1,
4980                },
4981                mip_level_count: 1,
4982                sample_count: 1,
4983                dimension: wgpu::TextureDimension::D2,
4984                format: wgpu::TextureFormat::R16Unorm,
4985                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4986                view_formats: &[],
4987            });
4988            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
4989
4990            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
4991                label: Some("p010 UV"),
4992                size: wgpu::Extent3d {
4993                    width: uv_w,
4994                    height: uv_h,
4995                    depth_or_array_layers: 1,
4996                },
4997                mip_level_count: 1,
4998                sample_count: 1,
4999                dimension: wgpu::TextureDimension::D2,
5000                format: wgpu::TextureFormat::Rg16Unorm,
5001                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5002                view_formats: &[],
5003            });
5004            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5005
5006            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5007                label: Some("p010 yuv transform"),
5008                size: std::mem::size_of::<YuvTransformRaw>() as u64,
5009                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5010                mapped_at_creation: false,
5011            });
5012            self.queue
5013                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5014
5015            let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "p010 bind");
5016
5017            let bytes = checked_image_bytes(w, h, 2)?
5018                .checked_add(checked_image_bytes(uv_w, uv_h, 4)?)
5019                .and_then(|bytes| bytes.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5020                .ok_or_else(|| anyhow::anyhow!("P010 image byte size overflow"))?;
5021            self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5022
5023            self.images.insert(
5024                handle,
5025                ImageTex::Nv12 {
5026                    tex_y,
5027                    tex_uv,
5028                    binds,
5029                    yuv_buf,
5030                    w,
5031                    h,
5032                    pixel_format: PixelFormat::P010,
5033                    fourcc: Some(P010_FOURCC),
5034                    color_info,
5035                    external: false,
5036                    last_used_frame: self.frame_index,
5037                    bytes,
5038                },
5039            );
5040        } else {
5041            if let Some(ImageTex::Nv12 {
5042                yuv_buf,
5043                color_info: stored_color,
5044                last_used_frame,
5045                ..
5046            }) = self.images.get_mut(&handle)
5047            {
5048                *stored_color = color_info;
5049                *last_used_frame = self.frame_index;
5050                self.queue
5051                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5052            }
5053        }
5054
5055        let (tex_y, tex_uv, _binds) = match self.images.get(&handle) {
5056            Some(ImageTex::Nv12 {
5057                tex_y,
5058                tex_uv,
5059                binds,
5060                ..
5061            }) => (tex_y, tex_uv, binds),
5062            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
5063        };
5064
5065        self.queue.write_texture(
5066            wgpu::TexelCopyTextureInfo {
5067                texture: tex_y,
5068                mip_level: 0,
5069                origin: wgpu::Origin3d::ZERO,
5070                aspect: wgpu::TextureAspect::All,
5071            },
5072            &y[..y_expected],
5073            wgpu::TexelCopyBufferLayout {
5074                offset: 0,
5075                bytes_per_row: Some(
5076                    w.checked_mul(2)
5077                        .ok_or_else(|| anyhow::anyhow!("P010 row size overflow"))?,
5078                ),
5079                rows_per_image: Some(h),
5080            },
5081            wgpu::Extent3d {
5082                width: w,
5083                height: h,
5084                depth_or_array_layers: 1,
5085            },
5086        );
5087        self.queue.write_texture(
5088            wgpu::TexelCopyTextureInfo {
5089                texture: tex_uv,
5090                mip_level: 0,
5091                origin: wgpu::Origin3d::ZERO,
5092                aspect: wgpu::TextureAspect::All,
5093            },
5094            &uv[..uv_expected],
5095            wgpu::TexelCopyBufferLayout {
5096                offset: 0,
5097                bytes_per_row: Some(
5098                    uv_w.checked_mul(4)
5099                        .ok_or_else(|| anyhow::anyhow!("P010 UV row size overflow"))?,
5100                ),
5101                rows_per_image: Some(uv_h),
5102            },
5103            wgpu::Extent3d {
5104                width: uv_w,
5105                height: uv_h,
5106                depth_or_array_layers: 1,
5107            },
5108        );
5109
5110        self.evict_budget_excess();
5111        Ok(())
5112    }
5113
5114    #[cfg(target_os = "linux")]
5115    pub fn set_image_dmabuf(
5116        &mut self,
5117        handle: u64,
5118        w: u32,
5119        h: u32,
5120        fds: Vec<std::os::unix::io::OwnedFd>,
5121        modifier: u64,
5122        strides: Vec<u32>,
5123        offsets: Vec<u64>,
5124        color_info: ColorInfo,
5125    ) -> anyhow::Result<()> {
5126        self.set_image_dmabuf_fourcc(
5127            handle,
5128            w,
5129            h,
5130            fds,
5131            NV12_FOURCC,
5132            modifier,
5133            strides,
5134            offsets,
5135            color_info,
5136        )
5137    }
5138
5139    #[cfg(target_os = "linux")]
5140    pub fn set_image_dmabuf_fourcc(
5141        &mut self,
5142        handle: u64,
5143        w: u32,
5144        h: u32,
5145        fds: Vec<std::os::unix::io::OwnedFd>,
5146        fourcc: u32,
5147        modifier: u64,
5148        strides: Vec<u32>,
5149        offsets: Vec<u64>,
5150        color_info: ColorInfo,
5151    ) -> anyhow::Result<()> {
5152        validate_image_handle(handle)?;
5153        checked_image_bytes(w, h, 1)?;
5154        if w == 0
5155            || h == 0
5156            || w > self.device.limits().max_texture_dimension_2d
5157            || h > self.device.limits().max_texture_dimension_2d
5158        {
5159            anyhow::bail!("DMA-BUF dimensions are outside the device texture limit");
5160        }
5161        if modifier == u64::MAX {
5162            anyhow::bail!("DMA-BUF modifier is invalid");
5163        }
5164        if fds.len() != 2 || strides.len() != 2 || offsets.len() != 2 {
5165            anyhow::bail!(
5166                "DMA-BUF import requires two fds, strides, and offsets (got {}, {}, {})",
5167                fds.len(),
5168                strides.len(),
5169                offsets.len()
5170            );
5171        }
5172        let pixel_format = match fourcc {
5173            0x3231_564e | 0x4e56_3132 => PixelFormat::Nv12,
5174            0x3031_3050 | 0x5030_3130 => PixelFormat::P010,
5175            _ => anyhow::bail!("unsupported DMA-BUF fourcc 0x{fourcc:08x}"),
5176        };
5177        if !self
5178            .device
5179            .features()
5180            .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF)
5181        {
5182            anyhow::bail!("DMA-BUF import requires VULKAN_EXTERNAL_MEMORY_DMA_BUF");
5183        }
5184        if pixel_format == PixelFormat::P010
5185            && !self
5186                .device
5187                .features()
5188                .contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
5189        {
5190            anyhow::bail!("P010 DMA-BUF requires TEXTURE_FORMAT_16BIT_NORM");
5191        }
5192        let bytes_per_pixel = if pixel_format == PixelFormat::P010 {
5193            2
5194        } else {
5195            1
5196        };
5197        let uv_w = w.div_ceil(2);
5198        let uv_h = h.div_ceil(2);
5199        let y_file_size =
5200            validate_dmabuf_plane(&fds[0], offsets[0], strides[0], w, h, bytes_per_pixel)?;
5201        let uv_file_size = validate_dmabuf_plane(
5202            &fds[1],
5203            offsets[1],
5204            strides[1],
5205            uv_w,
5206            uv_h,
5207            bytes_per_pixel * 2,
5208        )?;
5209        let yuv_raw = make_yuv_transform_raw(color_info, pixel_format);
5210        let (y_format, uv_format) = if pixel_format == PixelFormat::P010 {
5211            (
5212                wgpu::TextureFormat::R16Unorm,
5213                wgpu::TextureFormat::Rg16Unorm,
5214            )
5215        } else {
5216            (wgpu::TextureFormat::R8Unorm, wgpu::TextureFormat::Rg8Unorm)
5217        };
5218        let hal_y_desc = wgpu::hal::TextureDescriptor {
5219            label: Some("dmabuf y"),
5220            size: wgpu::Extent3d {
5221                width: w,
5222                height: h,
5223                depth_or_array_layers: 1,
5224            },
5225            mip_level_count: 1,
5226            sample_count: 1,
5227            dimension: wgpu::TextureDimension::D2,
5228            format: y_format,
5229            usage: wgpu::wgt::TextureUses::RESOURCE,
5230            memory_flags: wgpu::hal::MemoryFlags::empty(),
5231            view_formats: vec![],
5232        };
5233        let hal_uv_desc = wgpu::hal::TextureDescriptor {
5234            label: Some("dmabuf uv"),
5235            size: wgpu::Extent3d {
5236                width: uv_w,
5237                height: uv_h,
5238                depth_or_array_layers: 1,
5239            },
5240            mip_level_count: 1,
5241            sample_count: 1,
5242            dimension: wgpu::TextureDimension::D2,
5243            format: uv_format,
5244            usage: wgpu::wgt::TextureUses::RESOURCE,
5245            memory_flags: wgpu::hal::MemoryFlags::empty(),
5246            view_formats: vec![],
5247        };
5248        let wgpu_y_desc = wgpu::TextureDescriptor {
5249            label: Some("dmabuf y"),
5250            size: hal_y_desc.size,
5251            mip_level_count: 1,
5252            sample_count: 1,
5253            dimension: wgpu::TextureDimension::D2,
5254            format: y_format,
5255            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5256            view_formats: &[],
5257        };
5258        let wgpu_uv_desc = wgpu::TextureDescriptor {
5259            label: Some("dmabuf uv"),
5260            size: hal_uv_desc.size,
5261            mip_level_count: 1,
5262            sample_count: 1,
5263            dimension: wgpu::TextureDimension::D2,
5264            format: uv_format,
5265            usage: wgpu::TextureUsages::TEXTURE_BINDING,
5266            view_formats: &[],
5267        };
5268
5269        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
5270            let hal_guard = self
5271                .device
5272                .as_hal::<wgpu::hal::vulkan::Api>()
5273                .ok_or_else(|| anyhow::anyhow!("device is not Vulkan"))?;
5274            let mut fds = fds.into_iter();
5275            let y_fd = fds.next().expect("validated fd count");
5276            let uv_fd = fds.next().expect("validated fd count");
5277            let y_texture = hal_guard.texture_from_dmabuf_fd(
5278                y_fd,
5279                &hal_y_desc,
5280                modifier,
5281                strides[0] as u64,
5282                offsets[0],
5283            );
5284            let y_texture =
5285                y_texture.map_err(|error| anyhow::anyhow!("import Y DMA-BUF: {error:?}"))?;
5286            let uv_texture = hal_guard.texture_from_dmabuf_fd(
5287                uv_fd,
5288                &hal_uv_desc,
5289                modifier,
5290                strides[1] as u64,
5291                offsets[1],
5292            );
5293            let uv_texture =
5294                uv_texture.map_err(|error| anyhow::anyhow!("import UV DMA-BUF: {error:?}"))?;
5295            drop(hal_guard);
5296            let tex_y = self
5297                .device
5298                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5299                    y_texture,
5300                    &wgpu_y_desc,
5301                    wgpu::wgt::TextureUses::UNINITIALIZED,
5302                );
5303            let tex_uv = self
5304                .device
5305                .create_texture_from_hal::<wgpu::hal::vulkan::Api>(
5306                    uv_texture,
5307                    &wgpu_uv_desc,
5308                    wgpu::wgt::TextureUses::UNINITIALIZED,
5309                );
5310            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
5311            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
5312            (tex_y, view_y, tex_uv, view_uv)
5313        };
5314
5315        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
5316            label: Some("dmabuf yuv transform"),
5317            size: std::mem::size_of::<YuvTransformRaw>() as u64,
5318            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
5319            mapped_at_creation: false,
5320        });
5321        self.queue
5322            .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
5323        let binds = self.create_nv12_binds(&view_y, &view_uv, &yuv_buf, "dmabuf yuv bind");
5324        let bytes = y_file_size
5325            .checked_add(uv_file_size)
5326            .and_then(|value| value.checked_add(std::mem::size_of::<YuvTransformRaw>() as u64))
5327            .ok_or_else(|| anyhow::anyhow!("DMA-BUF byte size overflow"))?;
5328        self.evict_budget_excess();
5329        let replaced_bytes = self
5330            .images
5331            .get(&handle)
5332            .map(|image| match image {
5333                ImageTex::Rgba { bytes, .. }
5334                | ImageTex::Nv12 { bytes, .. }
5335                | ImageTex::User { bytes, .. } => *bytes,
5336            })
5337            .unwrap_or(0);
5338        let projected = self
5339            .image_bytes_total
5340            .saturating_sub(replaced_bytes)
5341            .checked_add(bytes)
5342            .ok_or_else(|| anyhow::anyhow!("DMA-BUF image budget overflow"))?;
5343        if projected > self.image_budget_bytes {
5344            anyhow::bail!("DMA-BUF image budget exceeded");
5345        }
5346        self.remove_image(handle);
5347        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5348        self.images.insert(
5349            handle,
5350            ImageTex::Nv12 {
5351                tex_y,
5352                tex_uv,
5353                binds,
5354                yuv_buf,
5355                w,
5356                h,
5357                pixel_format,
5358                fourcc: Some(canonical_fourcc(pixel_format)),
5359                color_info,
5360                external: true,
5361                last_used_frame: self.frame_index,
5362                bytes,
5363            },
5364        );
5365        self.evict_budget_excess();
5366        Ok(())
5367    }
5368
5369    pub fn image_fourcc(&self, handle: u64) -> Option<u32> {
5370        match self.images.get(&handle) {
5371            Some(ImageTex::Nv12 { fourcc, .. }) => *fourcc,
5372            _ => None,
5373        }
5374    }
5375
5376    pub fn remove_image(&mut self, handle: u64) {
5377        if let Some(img) = self.images.remove(&handle) {
5378            let b = match &img {
5379                ImageTex::Rgba { bytes, .. } => *bytes,
5380                ImageTex::Nv12 { bytes, .. } => *bytes,
5381                ImageTex::User { bytes, .. } => *bytes,
5382            };
5383            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5384        }
5385        if let Some(retained) = self.retained.remove(&handle) {
5386            self.retained_bytes_total = self
5387                .retained_bytes_total
5388                .saturating_sub(retained.rgba.len() as u64);
5389        }
5390    }
5391
5392    fn evict_image_gpu(&mut self, handle: u64) -> u64 {
5393        let Some(img) = self.images.remove(&handle) else {
5394            return 0;
5395        };
5396        let b = match &img {
5397            ImageTex::Rgba { bytes, .. } => *bytes,
5398            ImageTex::Nv12 { bytes, .. } => *bytes,
5399            ImageTex::User { bytes, .. } => *bytes,
5400        };
5401        self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
5402        b
5403    }
5404
5405    fn revive_retained_image(&mut self, handle: u64) -> anyhow::Result<bool> {
5406        if self.images.contains_key(&handle) {
5407            return Ok(true);
5408        }
5409        let Some(r) = self.retained.get_mut(&handle) else {
5410            return Ok(false);
5411        };
5412        r.last_used_frame = self.frame_index;
5413        let r = r.clone();
5414        let (tex, binds) = self.create_rgba_tex(r.w, r.h, r.format);
5415
5416        self.queue.write_texture(
5417            wgpu::TexelCopyTextureInfo {
5418                texture: &tex,
5419                mip_level: 0,
5420                origin: wgpu::Origin3d::ZERO,
5421                aspect: wgpu::TextureAspect::All,
5422            },
5423            &r.rgba,
5424            wgpu::TexelCopyBufferLayout {
5425                offset: 0,
5426                bytes_per_row: Some(4 * r.w),
5427                rows_per_image: Some(r.h),
5428            },
5429            wgpu::Extent3d {
5430                width: r.w,
5431                height: r.h,
5432                depth_or_array_layers: 1,
5433            },
5434        );
5435
5436        let bytes = checked_image_bytes(r.w, r.h, 4)?;
5437        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5438        self.images.insert(
5439            handle,
5440            ImageTex::Rgba {
5441                tex,
5442                binds,
5443                w: r.w,
5444                h: r.h,
5445                format: r.format,
5446                last_used_frame: self.frame_index,
5447                bytes,
5448            },
5449        );
5450        Ok(true)
5451    }
5452
5453    fn resolve_image_for_draw(&mut self, handle: u64) -> Option<(u32, u32, bool)> {
5454        if let Some(retained) = self.retained.get_mut(&handle) {
5455            retained.last_used_frame = self.frame_index;
5456        }
5457        if let Some(t) = self.images.get_mut(&handle) {
5458            return match t {
5459                ImageTex::Rgba {
5460                    w,
5461                    h,
5462                    last_used_frame,
5463                    ..
5464                } => {
5465                    *last_used_frame = self.frame_index;
5466                    Some((*w, *h, false))
5467                }
5468                ImageTex::User {
5469                    w,
5470                    h,
5471                    last_used_frame,
5472                    ..
5473                } => {
5474                    *last_used_frame = self.frame_index;
5475                    Some((*w, *h, false))
5476                }
5477                ImageTex::Nv12 {
5478                    w,
5479                    h,
5480                    last_used_frame,
5481                    ..
5482                } => {
5483                    *last_used_frame = self.frame_index;
5484                    Some((*w, *h, true))
5485                }
5486            };
5487        }
5488        if self.revive_retained_image(handle).unwrap_or(false)
5489            && let Some(ImageTex::Rgba {
5490                w,
5491                h,
5492                last_used_frame,
5493                ..
5494            }) = self.images.get_mut(&handle)
5495        {
5496            *last_used_frame = self.frame_index;
5497            return Some((*w, *h, false));
5498        }
5499        None
5500    }
5501
5502    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
5503    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
5504        let handle = self.next_image_handle;
5505        self.next_image_handle += 1;
5506        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
5507            log::error!("Failed to register image: {e}");
5508        }
5509        handle
5510    }
5511
5512    /// Register raw RGBA8 pixels (`w * h * 4` bytes) as an image, returning
5513    /// its handle for `SceneNode::Image`. Used by CPU-rasterized overlays
5514    /// (e.g. subtitle bitmap layers) that have no encoded image bytes.
5515    /// Pass `srgb = true` for sRGB overlays composited over video.
5516    pub fn register_image_rgba8(&mut self, w: u32, h: u32, rgba: &[u8], srgb: bool) -> u64 {
5517        let handle = self.next_image_handle;
5518        self.next_image_handle += 1;
5519        if let Err(e) = self.set_image_rgba8(handle, w, h, rgba, srgb) {
5520            log::error!("Failed to register image: {e}");
5521        }
5522        handle
5523    }
5524
5525    /// Register an 8-bit coverage tile (`w * h` bytes, 0 = empty, 255 =
5526    /// fully covered) for `SceneNode::Coverage`, returning its handle.
5527    /// Coverage tiles are immutable: re-register on geometry change and
5528    /// `remove_coverage` handles you no longer emit (stale tiles also age
5529    /// out under the image eviction policy).
5530    pub fn register_coverage_a8(&mut self, w: u32, h: u32, coverage: &[u8]) -> u64 {
5531        if validate_texture_dimensions(&self.device, w, h).is_err() {
5532            log::error!("Coverage dimensions are outside the device limit");
5533            return 0;
5534        }
5535        let Ok(expected_bytes) = checked_image_bytes(w, h, 1) else {
5536            log::error!("Coverage dimensions overflow");
5537            return 0;
5538        };
5539        let Ok(expected) = usize::try_from(expected_bytes) else {
5540            log::error!("Coverage dimensions exceed addressable memory");
5541            return 0;
5542        };
5543        if coverage.len() < expected {
5544            log::error!("Coverage buffer too small: {} < {expected}", coverage.len());
5545            return 0;
5546        }
5547        let handle = self.next_coverage_handle;
5548        self.next_coverage_handle += 1;
5549
5550        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5551            label: Some("coverage tile a8"),
5552            size: wgpu::Extent3d {
5553                width: w,
5554                height: h,
5555                depth_or_array_layers: 1,
5556            },
5557            mip_level_count: 1,
5558            sample_count: 1,
5559            dimension: wgpu::TextureDimension::D2,
5560            format: wgpu::TextureFormat::R8Unorm,
5561            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
5562            view_formats: &[],
5563        });
5564        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5565        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5566            label: Some("coverage bind a8"),
5567            layout: &self.image_bind_layout_rgba,
5568            entries: &[
5569                wgpu::BindGroupEntry {
5570                    binding: 0,
5571                    resource: wgpu::BindingResource::TextureView(&view),
5572                },
5573                wgpu::BindGroupEntry {
5574                    binding: 1,
5575                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5576                },
5577            ],
5578        });
5579        self.queue.write_texture(
5580            wgpu::TexelCopyTextureInfo {
5581                texture: &tex,
5582                mip_level: 0,
5583                origin: wgpu::Origin3d::ZERO,
5584                aspect: wgpu::TextureAspect::All,
5585            },
5586            &coverage[..expected],
5587            wgpu::TexelCopyBufferLayout {
5588                offset: 0,
5589                bytes_per_row: Some(w),
5590                rows_per_image: Some(h),
5591            },
5592            wgpu::Extent3d {
5593                width: w,
5594                height: h,
5595                depth_or_array_layers: 1,
5596            },
5597        );
5598        let bytes = checked_image_bytes(w, h, 1).unwrap_or(u64::MAX);
5599        self.image_bytes_total = self.image_bytes_total.saturating_add(bytes);
5600        self.coverages.insert(
5601            handle,
5602            CoverageTex {
5603                tex,
5604                bind,
5605                w,
5606                h,
5607                last_used_frame: self.frame_index,
5608                bytes,
5609            },
5610        );
5611        self.evict_budget_excess();
5612        handle
5613    }
5614
5615    /// Remove a coverage tile registered with [`register_coverage_a8`](Self::register_coverage_a8).
5616    pub fn remove_coverage(&mut self, handle: u64) {
5617        if let Some(tile) = self.coverages.remove(&handle) {
5618            self.image_bytes_total = self.image_bytes_total.saturating_sub(tile.bytes);
5619        }
5620    }
5621
5622    /// Tile dimensions, marking the handle used (keeps it alive under the
5623    /// eviction policy). Returns `None` for unknown handles.
5624    pub fn coverage_dimensions(&mut self, handle: u64) -> Option<(u32, u32)> {
5625        if let Some(tile) = self.coverages.get_mut(&handle) {
5626            tile.last_used_frame = self.frame_index;
5627            return Some((tile.w, tile.h));
5628        }
5629        None
5630    }
5631
5632    fn evict_retained_excess(&mut self) {
5633        let mut candidates: Vec<(u64, u64, u64)> = self
5634            .retained
5635            .iter()
5636            .map(|(handle, image)| (*handle, image.last_used_frame, image.rgba.len() as u64))
5637            .collect();
5638        candidates.sort_by_key(|candidate| candidate.1);
5639        for (handle, _last_used, bytes) in candidates {
5640            if self.retained.len() <= MAX_RETAINED_IMAGES
5641                && self.retained_bytes_total <= MAX_RETAINED_IMAGE_BYTES
5642            {
5643                break;
5644            }
5645            if let Some(image) = self.retained.remove(&handle) {
5646                self.retained_bytes_total = self
5647                    .retained_bytes_total
5648                    .saturating_sub(bytes.min(image.rgba.len() as u64));
5649            }
5650        }
5651    }
5652
5653    fn evict_unused_images(&mut self) {
5654        let now = self.frame_index;
5655        let evict_after = self.image_evict_after_frames;
5656
5657        // Time based eviction. Eviction only frees GPU memory: retained RGBA
5658        // sources stay so the image can be lazily re-uploaded when drawn again.
5659        let mut to_evict = Vec::new();
5660        for (h, t) in self.images.iter() {
5661            if !t.evictable() {
5662                continue;
5663            }
5664            let last = match t {
5665                ImageTex::Rgba {
5666                    last_used_frame, ..
5667                } => *last_used_frame,
5668                ImageTex::User {
5669                    last_used_frame, ..
5670                } => *last_used_frame,
5671                ImageTex::Nv12 {
5672                    last_used_frame, ..
5673                } => *last_used_frame,
5674            };
5675            if now.saturating_sub(last) > evict_after {
5676                to_evict.push(*h);
5677            }
5678        }
5679        for h in to_evict {
5680            if self.retained.contains_key(&h) {
5681                self.evict_image_gpu(h);
5682            } else {
5683                self.remove_image(h);
5684            }
5685        }
5686
5687        // Coverage tiles have no retained CPU copies: age-out removes them.
5688        let mut stale = Vec::new();
5689        for (h, t) in self.coverages.iter() {
5690            if now.saturating_sub(t.last_used_frame) > evict_after {
5691                stale.push(*h);
5692            }
5693        }
5694        for h in stale {
5695            self.remove_coverage(h);
5696        }
5697
5698        self.evict_budget_excess();
5699    }
5700
5701    fn next_cache_touch(&mut self) -> u64 {
5702        let touch = self.cache_touch_clock;
5703        self.cache_touch_clock = self.cache_touch_clock.wrapping_add(1);
5704        touch
5705    }
5706
5707    fn gpu_bytes_total(&self) -> u64 {
5708        self.image_bytes_total
5709            .saturating_add(self.layer_bytes_total)
5710            .saturating_add(self.blend_snapshot_bytes_total)
5711            .saturating_add(self.mesh_cache_bytes_total)
5712            .saturating_add(self.working_space_bytes)
5713            .saturating_add(self.surface_resolve_bytes)
5714            .saturating_add(self.msaa_bytes)
5715            .saturating_add(self.ws_msaa_bytes)
5716            .saturating_add(self.depth_stencil_bytes)
5717    }
5718
5719    fn budget_exceeded(&self) -> bool {
5720        self.image_bytes_total > self.image_budget_bytes
5721            || self.gpu_bytes_total() > self.gpu_budget_bytes
5722    }
5723
5724    fn evict_budget_excess(&mut self) {
5725        let image_over_budget = self.image_bytes_total > self.image_budget_bytes;
5726        let gpu_over_budget = self.gpu_bytes_total() > self.gpu_budget_bytes;
5727        if !image_over_budget && !gpu_over_budget {
5728            return;
5729        }
5730        let mut coverage_candidates: Vec<(u64, u64)> = self
5731            .coverages
5732            .iter()
5733            .map(|(handle, tile)| (*handle, tile.last_used_frame))
5734            .collect();
5735        coverage_candidates.sort_by_key(|candidate| candidate.1);
5736        let now = self.frame_index;
5737        for (handle, last_used) in coverage_candidates {
5738            if !self.budget_exceeded() {
5739                break;
5740            }
5741            if last_used == now {
5742                continue;
5743            }
5744            self.remove_coverage(handle);
5745        }
5746        if !self.budget_exceeded() {
5747            return;
5748        }
5749        // Collect (handle, last_used, bytes)
5750        let mut candidates: Vec<(u64, u64, u64)> = self
5751            .images
5752            .iter()
5753            .filter_map(|(h, t)| {
5754                if !t.evictable() {
5755                    return None;
5756                }
5757                let (last, bytes) = match t {
5758                    ImageTex::Rgba {
5759                        last_used_frame,
5760                        bytes,
5761                        ..
5762                    } => (*last_used_frame, *bytes),
5763                    ImageTex::User {
5764                        last_used_frame,
5765                        bytes,
5766                        ..
5767                    } => (*last_used_frame, *bytes),
5768                    ImageTex::Nv12 {
5769                        last_used_frame,
5770                        bytes,
5771                        ..
5772                    } => (*last_used_frame, *bytes),
5773                };
5774                Some((*h, last, bytes))
5775            })
5776            .collect();
5777
5778        // Sort by last_used ascending (LRU first)
5779        candidates.sort_by_key(|k| k.1);
5780
5781        for (h, last, _bytes) in candidates {
5782            if !self.budget_exceeded() {
5783                break;
5784            }
5785            if last == now {
5786                continue;
5787            }
5788            if self.retained.contains_key(&h) {
5789                self.evict_image_gpu(h);
5790            } else {
5791                self.remove_image(h);
5792            }
5793        }
5794        while self.gpu_bytes_total() > self.gpu_budget_bytes {
5795            if !self.evict_mesh_cache() {
5796                break;
5797            }
5798        }
5799        if self.gpu_bytes_total() > self.gpu_budget_bytes {
5800            log::warn!(
5801                "renderer GPU resource budget exceeded: {} > {}",
5802                self.gpu_bytes_total(),
5803                self.gpu_budget_bytes
5804            );
5805        }
5806    }
5807
5808    /// Set pixels per point (DPI scale) for callback `ScreenDescriptor` / `PaintCallbackInfo`.
5809    pub fn set_pixels_per_point(&mut self, ppp: f32) {
5810        if ppp.is_finite() {
5811            self.pixels_per_point = ppp.clamp(0.5, 8.0);
5812        }
5813    }
5814
5815    /// Enable or disable linear working-space rendering.
5816    /// When enabled, the scene is rendered into an Rgba16Float intermediate
5817    /// and a final full-screen pass applies the display OETF.
5818    pub fn set_working_space(&mut self, enabled: bool) {
5819        if enabled == self.working_space {
5820            return;
5821        }
5822        self.working_space = enabled;
5823        if enabled {
5824            self.ensure_display_pipeline();
5825            self.recreate_msaa_and_depth_stencil();
5826            self.recreate_working_space_texture();
5827        } else {
5828            self.ws_tex = None;
5829            self.ws_view = None;
5830            self.ws_bind = None;
5831            self.ws_msaa_tex = None;
5832            self.ws_msaa_view = None;
5833            self.surface_resolve_tex = None;
5834            self.surface_resolve_view = None;
5835            self.surface_resolve_bytes = 0;
5836            self.working_space_bytes = 0;
5837            self.recreate_msaa_and_depth_stencil();
5838        }
5839        self.evict_budget_excess();
5840    }
5841
5842    fn ensure_display_pipeline(&mut self) {
5843        if self.display_pipeline.is_some() {
5844            return;
5845        }
5846
5847        let layout = self
5848            .device
5849            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
5850                label: Some("display transform layout"),
5851                entries: &[
5852                    wgpu::BindGroupLayoutEntry {
5853                        binding: 0,
5854                        visibility: wgpu::ShaderStages::FRAGMENT,
5855                        ty: wgpu::BindingType::Texture {
5856                            multisampled: false,
5857                            view_dimension: wgpu::TextureViewDimension::D2,
5858                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
5859                        },
5860                        count: None,
5861                    },
5862                    wgpu::BindGroupLayoutEntry {
5863                        binding: 1,
5864                        visibility: wgpu::ShaderStages::FRAGMENT,
5865                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
5866                        count: None,
5867                    },
5868                ],
5869            });
5870        self.display_layout = Some(layout);
5871
5872        let display_source: Cow<'static, str> = if self.output_format.is_srgb() {
5873            Cow::Borrowed(include_str!("shaders/display_transform_passthrough.wgsl"))
5874        } else {
5875            Cow::Borrowed(include_str!("shaders/display_transform.wgsl"))
5876        };
5877        let shader = self
5878            .device
5879            .create_shader_module(wgpu::ShaderModuleDescriptor {
5880                label: Some("display transform"),
5881                source: wgpu::ShaderSource::Wgsl(display_source),
5882            });
5883
5884        let pipeline_layout = self
5885            .device
5886            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
5887                label: Some("display transform pipeline layout"),
5888                bind_group_layouts: &[None, self.display_layout.as_ref()],
5889                immediate_size: 0,
5890            });
5891
5892        let pipeline = self
5893            .device
5894            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
5895                label: Some("display transform pipeline"),
5896                layout: Some(&pipeline_layout),
5897                vertex: wgpu::VertexState {
5898                    module: &shader,
5899                    entry_point: Some("vs_main"),
5900                    buffers: &[],
5901                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5902                },
5903                fragment: Some(wgpu::FragmentState {
5904                    module: &shader,
5905                    entry_point: Some("fs_main"),
5906                    targets: &[Some(wgpu::ColorTargetState {
5907                        format: self.output_format,
5908                        blend: None,
5909                        write_mask: wgpu::ColorWrites::ALL,
5910                    })],
5911                    compilation_options: wgpu::PipelineCompilationOptions::default(),
5912                }),
5913                primitive: wgpu::PrimitiveState::default(),
5914                depth_stencil: None,
5915                multisample: wgpu::MultisampleState::default(),
5916                multiview_mask: None,
5917                cache: None,
5918            });
5919        self.display_pipeline = Some(pipeline);
5920    }
5921
5922    /// Resize the render target dimensions.
5923    ///
5924    /// Recreates MSAA, depth-stencil, and working-space textures to match the
5925    /// new size..
5926    pub fn resize(&mut self, width: u32, height: u32) {
5927        let (width, height) =
5928            clamp_output_size(width, height, self.device.limits().max_texture_dimension_2d);
5929        if self.output_width == width && self.output_height == height {
5930            return;
5931        }
5932        self.output_width = width;
5933        self.output_height = height;
5934        if width == 0 || height == 0 {
5935            self.msaa_tex = None;
5936            self.msaa_view = None;
5937            self.ws_msaa_tex = None;
5938            self.ws_msaa_view = None;
5939            self.surface_resolve_tex = None;
5940            self.surface_resolve_view = None;
5941            self.surface_resolve_bytes = 0;
5942            self.msaa_bytes = 0;
5943            self.ws_msaa_bytes = 0;
5944            self.depth_stencil_bytes = 0;
5945            self.working_space_bytes = 0;
5946            self.ws_tex = None;
5947            self.ws_view = None;
5948            self.ws_bind = None;
5949            self.evict_budget_excess();
5950            return;
5951        }
5952        self.recreate_msaa_and_depth_stencil();
5953        self.recreate_working_space_texture();
5954        self.evict_budget_excess();
5955    }
5956
5957    fn recreate_working_space_texture(&mut self) {
5958        if !self.working_space || self.output_width == 0 || self.output_height == 0 {
5959            return;
5960        }
5961        self.working_space_bytes = 0;
5962        let w = self
5963            .output_width
5964            .clamp(1, self.device.limits().max_texture_dimension_2d);
5965        let h = self
5966            .output_height
5967            .clamp(1, self.device.limits().max_texture_dimension_2d);
5968
5969        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
5970            label: Some("working space"),
5971            size: wgpu::Extent3d {
5972                width: w,
5973                height: h,
5974                depth_or_array_layers: 1,
5975            },
5976            mip_level_count: 1,
5977            sample_count: 1,
5978            dimension: wgpu::TextureDimension::D2,
5979            format: wgpu::TextureFormat::Rgba16Float,
5980            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
5981                | wgpu::TextureUsages::TEXTURE_BINDING
5982                | wgpu::TextureUsages::COPY_SRC
5983                | wgpu::TextureUsages::COPY_DST,
5984            view_formats: &[],
5985        });
5986        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5987
5988        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
5989            label: Some("working space bind"),
5990            layout: self.display_layout.as_ref().unwrap(),
5991            entries: &[
5992                wgpu::BindGroupEntry {
5993                    binding: 0,
5994                    resource: wgpu::BindingResource::TextureView(&view),
5995                },
5996                wgpu::BindGroupEntry {
5997                    binding: 1,
5998                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
5999                },
6000            ],
6001        });
6002
6003        self.working_space_bytes =
6004            texture_storage_bytes(wgpu::TextureFormat::Rgba16Float, w, h).unwrap_or(0);
6005        self.ws_tex = Some(tex);
6006        self.ws_view = Some(view);
6007        self.ws_bind = Some(bind);
6008    }
6009
6010    fn recreate_msaa_and_depth_stencil(&mut self) {
6011        if self.output_width == 0 || self.output_height == 0 {
6012            self.msaa_tex = None;
6013            self.msaa_view = None;
6014            self.ws_msaa_tex = None;
6015            self.ws_msaa_view = None;
6016            self.surface_resolve_tex = None;
6017            self.surface_resolve_view = None;
6018            self.surface_resolve_bytes = 0;
6019            self.msaa_bytes = 0;
6020            self.ws_msaa_bytes = 0;
6021            self.depth_stencil_bytes = 0;
6022            return;
6023        }
6024        self.msaa_bytes = 0;
6025        self.ws_msaa_bytes = 0;
6026        self.depth_stencil_bytes = 0;
6027        let aliased_surface = self.surface_format != self.output_format;
6028        let view_format_list = [self.output_format];
6029        let surface_view_formats: &[wgpu::TextureFormat] = if aliased_surface {
6030            &view_format_list
6031        } else {
6032            &[]
6033        };
6034        let surface_view = if aliased_surface {
6035            wgpu::TextureViewDescriptor {
6036                format: Some(self.output_format),
6037                ..Default::default()
6038            }
6039        } else {
6040            wgpu::TextureViewDescriptor::default()
6041        };
6042        if self.msaa_samples > 1 && !self.working_space {
6043            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6044                label: Some("msaa color"),
6045                size: wgpu::Extent3d {
6046                    width: self.output_width.max(1),
6047                    height: self.output_height.max(1),
6048                    depth_or_array_layers: 1,
6049                },
6050                mip_level_count: 1,
6051                sample_count: self.msaa_samples,
6052                dimension: wgpu::TextureDimension::D2,
6053                format: self.surface_format,
6054                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6055                view_formats: surface_view_formats,
6056            });
6057            let view = tex.create_view(&surface_view);
6058            self.msaa_bytes = texture_storage_bytes_with_samples(
6059                self.surface_format,
6060                self.output_width.max(1),
6061                self.output_height.max(1),
6062                self.msaa_samples,
6063            )
6064            .unwrap_or(0);
6065            self.msaa_tex = Some(tex);
6066            self.msaa_view = Some(view);
6067        } else {
6068            self.msaa_tex = None;
6069            self.msaa_view = None;
6070        }
6071        if self.working_space && self.working_space_msaa_samples > 1 {
6072            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6073                label: Some("working space msaa color"),
6074                size: wgpu::Extent3d {
6075                    width: self.output_width.max(1),
6076                    height: self.output_height.max(1),
6077                    depth_or_array_layers: 1,
6078                },
6079                mip_level_count: 1,
6080                sample_count: self.working_space_msaa_samples,
6081                dimension: wgpu::TextureDimension::D2,
6082                format: wgpu::TextureFormat::Rgba16Float,
6083                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6084                view_formats: &[],
6085            });
6086            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6087            self.ws_msaa_bytes = texture_storage_bytes_with_samples(
6088                wgpu::TextureFormat::Rgba16Float,
6089                self.output_width.max(1),
6090                self.output_height.max(1),
6091                self.working_space_msaa_samples,
6092            )
6093            .unwrap_or(0);
6094            self.ws_msaa_tex = Some(tex);
6095            self.ws_msaa_view = Some(view);
6096        } else {
6097            self.ws_msaa_tex = None;
6098            self.ws_msaa_view = None;
6099        }
6100        self.surface_resolve_tex = None;
6101        self.surface_resolve_view = None;
6102        self.surface_resolve_bytes = 0;
6103        if self.msaa_samples > 1 && !self.working_space {
6104            let width = self.output_width.max(1);
6105            let height = self.output_height.max(1);
6106            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6107                label: Some("surface resolve"),
6108                size: wgpu::Extent3d {
6109                    width,
6110                    height,
6111                    depth_or_array_layers: 1,
6112                },
6113                mip_level_count: 1,
6114                sample_count: 1,
6115                dimension: wgpu::TextureDimension::D2,
6116                format: self.surface_format,
6117                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6118                    | wgpu::TextureUsages::TEXTURE_BINDING
6119                    | wgpu::TextureUsages::COPY_SRC,
6120                view_formats: surface_view_formats,
6121            });
6122            let view = tex.create_view(&surface_view);
6123            self.surface_resolve_bytes =
6124                texture_storage_bytes(self.surface_format, width, height).unwrap_or(0);
6125            self.surface_resolve_tex = Some(tex);
6126            self.surface_resolve_view = Some(view);
6127        }
6128
6129        self.depth_stencil_bytes = texture_storage_bytes_with_samples(
6130            wgpu::TextureFormat::Depth24PlusStencil8,
6131            self.output_width.max(1),
6132            self.output_height.max(1),
6133            self.active_surface_msaa_samples(),
6134        )
6135        .unwrap_or(0);
6136        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
6137            label: Some("depth-stencil (stencil clips)"),
6138            size: wgpu::Extent3d {
6139                width: self.output_width.max(1),
6140                height: self.output_height.max(1),
6141                depth_or_array_layers: 1,
6142            },
6143            mip_level_count: 1,
6144            sample_count: self.active_surface_msaa_samples(),
6145            dimension: wgpu::TextureDimension::D2,
6146            format: wgpu::TextureFormat::Depth24PlusStencil8,
6147            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6148            view_formats: &[],
6149        });
6150        self.depth_stencil_view = self
6151            .depth_stencil_tex
6152            .create_view(&wgpu::TextureViewDescriptor::default());
6153    }
6154
6155    fn active_surface_msaa_samples(&self) -> u32 {
6156        if self.working_space {
6157            self.working_space_msaa_samples
6158        } else {
6159            self.msaa_samples
6160        }
6161    }
6162
6163    fn layer_target_format(&self) -> wgpu::TextureFormat {
6164        if self.working_space {
6165            wgpu::TextureFormat::Rgba16Float
6166        } else {
6167            self.output_format
6168        }
6169    }
6170
6171    fn get_or_create_layer(
6172        &mut self,
6173        layer_id: u32,
6174        width: u32,
6175        height: u32,
6176        rect: repose_core::Rect,
6177        transient: bool,
6178    ) -> bool {
6179        if width == 0
6180            || height == 0
6181            || width > self.device.limits().max_texture_dimension_2d
6182            || height > self.device.limits().max_texture_dimension_2d
6183        {
6184            return false;
6185        }
6186        let needs_alloc = match self.layer_pool.get(&layer_id) {
6187            Some(lt) => {
6188                lt.width != width || lt.height != height || lt.format != self.layer_target_format()
6189            }
6190            None => true,
6191        };
6192        if !needs_alloc {
6193            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
6194                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
6195            }
6196            return true;
6197        }
6198        let color_bytes =
6199            texture_storage_bytes(self.layer_target_format(), width.max(1), height.max(1))
6200                .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6201        let depth_bytes = checked_image_bytes(width.max(1), height.max(1), 4)
6202            .unwrap_or(MAX_GRAPHICS_LAYER_BYTES + 1);
6203        let bytes = color_bytes.saturating_add(depth_bytes);
6204        let old = self.layer_pool.get(&layer_id);
6205        let old_bytes = old.map_or(0, |layer| layer.bytes);
6206        let old_transient_bytes =
6207            old.map_or(0, |layer| if layer.transient { layer.bytes } else { 0 });
6208        let transient_count = self
6209            .layer_pool
6210            .values()
6211            .filter(|layer| layer.transient)
6212            .count();
6213        let transient_count = transient_count
6214            .saturating_sub(usize::from(old.is_some_and(|layer| layer.transient)))
6215            .saturating_add(usize::from(transient));
6216        let count = self.layer_pool.len() + usize::from(!self.layer_pool.contains_key(&layer_id));
6217        let total = self
6218            .layer_bytes_total
6219            .saturating_sub(old_bytes)
6220            .saturating_add(bytes);
6221        let projected_gpu = self
6222            .gpu_bytes_total()
6223            .saturating_sub(old_bytes)
6224            .saturating_add(bytes);
6225        let projected_transient = self
6226            .transient_layer_bytes_total
6227            .saturating_sub(old_transient_bytes)
6228            .saturating_add(if transient { bytes } else { 0 });
6229        if count > MAX_GRAPHICS_LAYERS
6230            || (transient
6231                && (transient_count > MAX_TRANSIENT_GRAPHICS_LAYERS
6232                    || projected_transient > MAX_TRANSIENT_GRAPHICS_LAYER_BYTES))
6233            || bytes > MAX_GRAPHICS_LAYER_BYTES
6234            || total > MAX_GRAPHICS_LAYER_BYTES
6235            || projected_gpu > self.gpu_budget_bytes
6236        {
6237            log::warn!("graphics layer budget exhausted; layer {layer_id} skipped");
6238            if let Some(old) = self.layer_pool.remove(&layer_id) {
6239                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6240                if old.transient {
6241                    self.transient_layer_bytes_total =
6242                        self.transient_layer_bytes_total.saturating_sub(old.bytes);
6243                }
6244            }
6245            return false;
6246        }
6247        if let Some(old) = self.layer_pool.remove(&layer_id) {
6248            self.layer_bytes_total = self.layer_bytes_total.saturating_sub(old.bytes);
6249            if old.transient {
6250                self.transient_layer_bytes_total =
6251                    self.transient_layer_bytes_total.saturating_sub(old.bytes);
6252            }
6253        }
6254        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6255            label: Some("graphics layer"),
6256            size: wgpu::Extent3d {
6257                width: width.max(1),
6258                height: height.max(1),
6259                depth_or_array_layers: 1,
6260            },
6261            mip_level_count: 1,
6262            sample_count: 1,
6263            dimension: wgpu::TextureDimension::D2,
6264            format: self.layer_target_format(),
6265            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
6266                | wgpu::TextureUsages::TEXTURE_BINDING
6267                | wgpu::TextureUsages::COPY_SRC,
6268            view_formats: &[],
6269        });
6270        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
6271        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6272            label: Some("layer bind"),
6273            layout: &self.image_bind_layout_rgba,
6274            entries: &[
6275                wgpu::BindGroupEntry {
6276                    binding: 0,
6277                    resource: wgpu::BindingResource::TextureView(&view),
6278                },
6279                wgpu::BindGroupEntry {
6280                    binding: 1,
6281                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
6282                },
6283            ],
6284        });
6285        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6286            label: Some("layer bind linear"),
6287            layout: &self.image_bind_layout_rgba,
6288            entries: &[
6289                wgpu::BindGroupEntry {
6290                    binding: 0,
6291                    resource: wgpu::BindingResource::TextureView(&view),
6292                },
6293                wgpu::BindGroupEntry {
6294                    binding: 1,
6295                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
6296                },
6297            ],
6298        });
6299        let depth_stencil_view = self.create_layer_depth_stencil(width, height);
6300        self.layer_pool.insert(
6301            layer_id,
6302            LayerTarget {
6303                texture: tex,
6304                view,
6305                bind,
6306                bind_linear,
6307                depth_stencil_view,
6308                width,
6309                height,
6310                format: self.layer_target_format(),
6311                bytes,
6312                rect_px: (rect.x, rect.y, rect.w, rect.h),
6313                transient,
6314            },
6315        );
6316        self.layer_bytes_total = self.layer_bytes_total.saturating_add(bytes);
6317        if transient {
6318            self.transient_layer_bytes_total =
6319                self.transient_layer_bytes_total.saturating_add(bytes);
6320        }
6321        true
6322    }
6323
6324    /// Allocate (or reuse) the padded scratch layer holding the horizontal half
6325    /// of a separable blur for `layer_id` in `slot`.
6326    ///
6327    /// Scratch ids are handed out from [`BLUR_SCRATCH_ID_BASE`] and remembered
6328    /// per `(layer_id, slot)` in [`Self::blur_scratch_ids`], so a stable layer
6329    /// reuses its allocation across frames while a content blur and a shadow
6330    /// on the same layer never share one. Allocation goes through the layer
6331    /// pool (as a transient layer) and therefore respects the layer budget;
6332    /// `None` means the caller should composite unblurred.
6333    fn acquire_blur_scratch(&mut self, layer_id: u32, slot: u32, pad: (f32, f32)) -> Option<u32> {
6334        let src = self.layer_pool.get(&layer_id)?;
6335        let width_f = (src.width as f32 + pad.0 * 2.0).ceil();
6336        let height_f = (src.height as f32 + pad.1 * 2.0).ceil();
6337        let max_dim = self.device.limits().max_texture_dimension_2d as f32;
6338        if !width_f.is_finite()
6339            || !height_f.is_finite()
6340            || width_f < 1.0
6341            || height_f < 1.0
6342            || width_f > max_dim
6343            || height_f > max_dim
6344        {
6345            log::warn!("blur scratch size {width_f}x{height_f} is invalid");
6346            return None;
6347        }
6348        let next = self.blur_scratch_next;
6349        let scratch_id = match self.blur_scratch_ids.get(&(layer_id, slot)) {
6350            Some(&id) => id,
6351            None => {
6352                let id = BLUR_SCRATCH_ID_BASE.wrapping_add(next);
6353                self.blur_scratch_next = next.wrapping_add(1);
6354                self.blur_scratch_ids.insert((layer_id, slot), id);
6355                id
6356            }
6357        };
6358        let allocated = self.get_or_create_layer(
6359            scratch_id,
6360            width_f as u32,
6361            height_f as u32,
6362            repose_core::Rect {
6363                x: 0.0,
6364                y: 0.0,
6365                w: width_f,
6366                h: height_f,
6367            },
6368            true,
6369        );
6370        allocated.then_some(scratch_id)
6371    }
6372
6373    /// Emit the horizontal half of a separable blur for `source_layer` into a
6374    /// padded scratch layer, and return that scratch's id so the caller can
6375    /// finish the vertical axis when it composites.
6376    ///
6377    /// The scratch pass is spliced between the pass being built and the pass the
6378    /// caller continues in, so ordering stays: everything drawn so far, the
6379    /// horizontal blur, then the vertical composite. `None` means no scratch
6380    /// could be allocated and the caller should composite without the blur.
6381    ///
6382    /// `slot` picks the scratch this blur owns, so concurrent blurs of one
6383    /// layer (content and shadow) get their own texture. The horizontal
6384    /// instance carries no tint: the scratch holds the raw blurred signal and
6385    /// the caller applies the group alpha / shadow colour on the vertical pass,
6386    /// which would otherwise square the alpha across the two axes.
6387    ///
6388    /// `alpha_only` selects the shadow kernel (alpha, premultiplied by the
6389    /// tint the caller applies vertically) over the full-RGBA kernel used by
6390    /// `Modifier::blur`.
6391    #[allow(clippy::too_many_arguments)]
6392    fn emit_separable_blur(
6393        &mut self,
6394        source_layer: u32,
6395        slot: u32,
6396        sigma: [f32; 2],
6397        pad: (f32, f32),
6398        edge_mode: u32,
6399        alpha_only: bool,
6400        shape: [f32; 4],
6401        passes: &mut Vec<Pass>,
6402        current_pass: &mut Pass,
6403        next_pass_id: &mut u64,
6404        encoder: &mut wgpu::CommandEncoder,
6405    ) -> Option<u32> {
6406        let scratch_id = self.acquire_blur_scratch(source_layer, slot, pad)?;
6407        let (scratch_w, scratch_h, source_w, source_h) = {
6408            let scratch = self.layer_pool.get(&scratch_id)?;
6409            let source = self.layer_pool.get(&source_layer)?;
6410            (
6411                scratch.width,
6412                scratch.height,
6413                source.width as f32,
6414                source.height as f32,
6415            )
6416        };
6417        // The quad covers the whole scratch, and scratch texel (x, y) samples
6418        // the source at (x - pad_x, y - pad_y). Padding texels therefore land
6419        // outside the source's 0..1, and `edge_mode` decides whether that
6420        // contributes nothing (decal, correct for a shadow) or clamps to the
6421        // edge texel (the `Modifier::blur` clamp edge treatment).
6422        let uv = [
6423            -pad.0 / source_w,
6424            -pad.1 / source_h,
6425            1.0 + pad.0 / source_w,
6426            1.0 + pad.1 / source_h,
6427        ];
6428        let ndc = [-1.0, -1.0, 2.0, 2.0];
6429        let inst = BlurInstance {
6430            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
6431            uv,
6432            color: [1.0, 1.0, 1.0, 1.0],
6433            sigma_px: sigma,
6434            fwd_mat: [1.0, 0.0, 0.0, 1.0],
6435            axis: 0,
6436            edge_mode,
6437            shape,
6438        };
6439        let cmd = match self.upload_blur_instance(&inst, encoder) {
6440            Some(off) if alpha_only => Cmd::CompositeShadow {
6441                off,
6442                cnt: 1,
6443                layer_id: source_layer,
6444            },
6445            Some(off) => Cmd::CompositeBlur {
6446                off,
6447                cnt: 1,
6448                layer_id: source_layer,
6449            },
6450            None => return None,
6451        };
6452        let target = current_pass.target;
6453        let scissor = current_pass.active_scissor;
6454        let resumed_pass_id = *next_pass_id;
6455        *next_pass_id += 1;
6456        let saved = std::mem::replace(
6457            current_pass,
6458            Pass {
6459                id: resumed_pass_id,
6460                target,
6461                initial_scissor: scissor.unwrap_or((0, 0, 1, 1)),
6462                active_scissor: scissor,
6463                clear_color: None,
6464                cmds: Vec::new(),
6465            },
6466        );
6467        passes.push(saved);
6468        let scratch_pass_id = *next_pass_id;
6469        *next_pass_id += 1;
6470        passes.push(Pass {
6471            id: scratch_pass_id,
6472            target: PassTarget::Layer(scratch_id),
6473            initial_scissor: (0, 0, scratch_w, scratch_h),
6474            active_scissor: Some((0, 0, scratch_w, scratch_h)),
6475            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
6476            cmds: vec![cmd],
6477        });
6478        Some(scratch_id)
6479    }
6480
6481    fn upload_blur_instance(
6482        &mut self,
6483        inst: &BlurInstance,
6484        encoder: &mut wgpu::CommandEncoder,
6485    ) -> Option<u64> {
6486        self.blur_ring
6487            .grow_to_fit(
6488                &self.device,
6489                encoder,
6490                std::mem::size_of::<BlurInstance>() as u64,
6491            )
6492            .ok()?;
6493        self.blur_ring
6494            .alloc_write(&self.queue, bytemuck::bytes_of(inst))
6495            .ok()
6496    }
6497    fn create_layer_depth_stencil(&self, width: u32, height: u32) -> wgpu::TextureView {
6498        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6499            label: Some("layer depth-stencil"),
6500            size: wgpu::Extent3d {
6501                width: width.max(1),
6502                height: height.max(1),
6503                depth_or_array_layers: 1,
6504            },
6505            mip_level_count: 1,
6506            sample_count: 1,
6507            dimension: wgpu::TextureDimension::D2,
6508            format: wgpu::TextureFormat::Depth24PlusStencil8,
6509            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
6510            view_formats: &[],
6511        });
6512        tex.create_view(&wgpu::TextureViewDescriptor::default())
6513    }
6514
6515    fn ensure_globals_staging(&mut self, pass_count: usize) -> bool {
6516        let size = std::mem::size_of::<Globals>() as u64;
6517        let needed = match (pass_count.max(1) as u64).checked_mul(size) {
6518            Some(needed) => needed,
6519            None => return false,
6520        };
6521        if needed <= self.globals_staging_cap {
6522            return true;
6523        }
6524        let cap = needed
6525            .next_power_of_two()
6526            .min(self.device.limits().max_buffer_size);
6527        if cap < needed {
6528            return false;
6529        }
6530        self.globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
6531            label: Some("globals staging"),
6532            size: cap,
6533            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
6534            mapped_at_creation: false,
6535        });
6536        self.globals_staging_cap = cap;
6537        true
6538    }
6539
6540    fn reset_atlas_storage(&mut self) {
6541        self.atlas_mask = init_atlas_mask(&self.device);
6542        self.atlas_color = init_atlas_color(&self.device);
6543        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6544            label: Some("atlas mask bind"),
6545            layout: &self.text_bind_layout,
6546            entries: &[
6547                wgpu::BindGroupEntry {
6548                    binding: 0,
6549                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6550                },
6551                wgpu::BindGroupEntry {
6552                    binding: 1,
6553                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6554                },
6555            ],
6556        });
6557        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6558            label: Some("atlas color bind"),
6559            layout: &self.text_bind_layout,
6560            entries: &[
6561                wgpu::BindGroupEntry {
6562                    binding: 0,
6563                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6564                },
6565                wgpu::BindGroupEntry {
6566                    binding: 1,
6567                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6568                },
6569            ],
6570        });
6571        self.atlas_mask_pending = AtlasPending::default();
6572        self.atlas_color_pending = AtlasPending::default();
6573        self.atlas_mask_deferred.clear();
6574        self.atlas_color_deferred.clear();
6575    }
6576
6577    /// Apply atlas growth and compaction scheduled during the previous frame.
6578    /// Called from `begin_frame`, before any glyph is batched: both operations
6579    /// re-pack the atlas, so running them mid-frame would leave already-batched
6580    /// instances pointing at the old packing.
6581    fn apply_atlas_pending(&mut self) {
6582        if self.atlas_mask_pending.grow {
6583            self.atlas_mask_pending.grow = false;
6584            let size = (self.atlas_mask.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6585            self.repack_mask(size);
6586        }
6587        if self.atlas_color_pending.grow {
6588            self.atlas_color_pending.grow = false;
6589            let size = (self.atlas_color.size * 2).min(MAX_GLYPH_ATLAS_SIZE);
6590            self.repack_color(size);
6591        }
6592        if self.frame_index.wrapping_sub(self.atlas_last_compact_frame)
6593            < ATLAS_COMPACT_MIN_FRAME_INTERVAL
6594        {
6595            return;
6596        }
6597        // Stay armed when a compaction finds nothing evictable, so the deferred
6598        // glyphs it would rescue get another chance next frame.
6599        if self.atlas_mask_pending.compact && self.compact_atlas_mask() {
6600            self.atlas_mask_pending.compact = false;
6601        }
6602        if self.atlas_color_pending.compact && self.compact_atlas_color() {
6603            self.atlas_color_pending.compact = false;
6604        }
6605    }
6606
6607    /// Drop the least recently used mask glyphs and re-pack at the same size.
6608    /// A shelf packer cannot hand back an individual cell, so reclaiming space
6609    /// means evicting cold entries and rebuilding around the warm ones. Returns
6610    /// false when there was nothing to evict.
6611    fn compact_atlas_mask(&mut self) -> bool {
6612        let target = self.atlas_mask.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6613        if !evict_atlas_lru(
6614            &mut self.atlas_mask.map,
6615            &mut self.atlas_mask.used_area,
6616            self.frame_index,
6617            target,
6618        ) {
6619            return false;
6620        }
6621        let size = self.atlas_mask.size;
6622        self.repack_mask(size);
6623        self.atlas_last_compact_frame = self.frame_index;
6624        true
6625    }
6626
6627    fn compact_atlas_color(&mut self) -> bool {
6628        let target = self.atlas_color.used_area / ATLAS_COMPACT_KEEP_DEN * ATLAS_COMPACT_KEEP_NUM;
6629        if !evict_atlas_lru(
6630            &mut self.atlas_color.map,
6631            &mut self.atlas_color.used_area,
6632            self.frame_index,
6633            target,
6634        ) {
6635            return false;
6636        }
6637        let size = self.atlas_color.size;
6638        self.repack_color(size);
6639        self.atlas_last_compact_frame = self.frame_index;
6640        true
6641    }
6642
6643    /// Re-pack the mask atlas into a `new_size` texture and re-upload every
6644    /// surviving entry, preserving its recency so a repack does not make warm
6645    /// glyphs look cold.
6646    fn repack_mask(&mut self, new_size: u32) {
6647        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6648            label: Some("glyph atlas A8 (repacked)"),
6649            size: wgpu::Extent3d {
6650                width: new_size,
6651                height: new_size,
6652                depth_or_array_layers: 1,
6653            },
6654            mip_level_count: 1,
6655            sample_count: 1,
6656            dimension: wgpu::TextureDimension::D2,
6657            format: wgpu::TextureFormat::R8Unorm,
6658            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6659            view_formats: &[],
6660        });
6661        self.atlas_mask.tex = tex;
6662        self.atlas_mask.view = self
6663            .atlas_mask
6664            .tex
6665            .create_view(&wgpu::TextureViewDescriptor::default());
6666        self.atlas_mask.size = new_size;
6667        self.atlas_mask.next_x = 1;
6668        self.atlas_mask.next_y = 1;
6669        self.atlas_mask.row_h = 0;
6670        self.atlas_mask.used_area = 0;
6671        self.atlas_mask_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6672            label: Some("atlas mask bind"),
6673            layout: &self.text_bind_layout,
6674            entries: &[
6675                wgpu::BindGroupEntry {
6676                    binding: 0,
6677                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
6678                },
6679                wgpu::BindGroupEntry {
6680                    binding: 1,
6681                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
6682                },
6683            ],
6684        });
6685        let survivors: Vec<_> = self.atlas_mask.map.drain().collect();
6686        self.atlas_mask_deferred.clear();
6687        for (key, previous) in survivors {
6688            let _ = self.upload_glyph_mask(key.clone(), f32::from_bits(key.font_size_bits));
6689            if let Some(info) = self.atlas_mask.map.get_mut(&key) {
6690                info.last_touch = previous.last_touch;
6691                info.last_used_frame = previous.last_used_frame;
6692            }
6693        }
6694    }
6695
6696    /// Re-pack the color atlas into a `new_size` texture. See [`Self::repack_mask`].
6697    fn repack_color(&mut self, new_size: u32) {
6698        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
6699            label: Some("glyph atlas RGBA (repacked)"),
6700            size: wgpu::Extent3d {
6701                width: new_size,
6702                height: new_size,
6703                depth_or_array_layers: 1,
6704            },
6705            mip_level_count: 1,
6706            sample_count: 1,
6707            dimension: wgpu::TextureDimension::D2,
6708            format: wgpu::TextureFormat::Rgba8UnormSrgb,
6709            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
6710            view_formats: &[],
6711        });
6712        self.atlas_color.tex = tex;
6713        self.atlas_color.view = self
6714            .atlas_color
6715            .tex
6716            .create_view(&wgpu::TextureViewDescriptor::default());
6717        self.atlas_color.size = new_size;
6718        self.atlas_color.next_x = 1;
6719        self.atlas_color.next_y = 1;
6720        self.atlas_color.row_h = 0;
6721        self.atlas_color.used_area = 0;
6722        self.atlas_color_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
6723            label: Some("atlas color bind"),
6724            layout: &self.text_bind_layout,
6725            entries: &[
6726                wgpu::BindGroupEntry {
6727                    binding: 0,
6728                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
6729                },
6730                wgpu::BindGroupEntry {
6731                    binding: 1,
6732                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
6733                },
6734            ],
6735        });
6736        let survivors: Vec<_> = self.atlas_color.map.drain().collect();
6737        self.atlas_color_deferred.clear();
6738        for (key, previous) in survivors {
6739            let _ = self.upload_glyph_color(key.clone(), f32::from_bits(key.font_size_bits));
6740            if let Some(info) = self.atlas_color.map.get_mut(&key) {
6741                info.last_touch = previous.last_touch;
6742                info.last_used_frame = previous.last_used_frame;
6743            }
6744        }
6745    }
6746
6747    fn sync_text_caches(&mut self) {
6748        let generation = repose_text::font_generation();
6749        if generation == self.atlas_generation {
6750            return;
6751        }
6752        self.atlas_generation = generation;
6753        self.reset_atlas_storage();
6754        self.atlas_mask_failures.clear();
6755        self.atlas_color_failures.clear();
6756        self.glyph_outline_cache.clear();
6757        self.legacy_shaped_text_cache.clear();
6758        self.slug_draw_cache.clear();
6759        self.slug_draw_cache_bytes_total = 0;
6760        self.slug_cache = slug::GlyphSlugCache::new();
6761    }
6762
6763    fn shape_text_cached(
6764        &mut self,
6765        text: &Arc<str>,
6766        px: f32,
6767        line_height_ratio: f32,
6768        font_family: Option<&'static str>,
6769        font_weight: u16,
6770        font_style: u8,
6771        letter_spacing: f32,
6772        variation: Option<&Arc<str>>,
6773    ) -> Arc<repose_text::SharedShapedText> {
6774        repose_text::shape_line_cached(
6775            text.as_ref(),
6776            px,
6777            line_height_ratio,
6778            font_family,
6779            font_weight,
6780            font_style,
6781            letter_spacing,
6782            variation.map(AsRef::as_ref),
6783        )
6784    }
6785
6786    fn legacy_shape_text_cached(
6787        &mut self,
6788        text: &Arc<str>,
6789        px: f32,
6790        line_height_ratio: f32,
6791        font_family: Option<&'static str>,
6792        font_weight: u16,
6793        font_style: u8,
6794        letter_spacing: f32,
6795        variation: Option<&Arc<str>>,
6796    ) -> Arc<[repose_text::ShapedGlyph]> {
6797        let key = TextShapeKey {
6798            text: text.clone(),
6799            px_bits: px.to_bits(),
6800            line_height_bits: line_height_ratio.to_bits(),
6801            font_family,
6802            font_weight,
6803            font_style,
6804            letter_spacing_bits: letter_spacing.to_bits(),
6805            variation: variation.cloned(),
6806            font_generation: self.atlas_generation,
6807        };
6808        if let Some(entry) = self.legacy_shaped_text_cache.get_mut(&key) {
6809            entry.last_used_frame = self.frame_index;
6810            return entry.glyphs.clone();
6811        }
6812        let glyphs: Arc<[repose_text::ShapedGlyph]> = Arc::from(repose_text::shape_line(
6813            text.as_ref(),
6814            px,
6815            line_height_ratio,
6816            font_family,
6817            font_weight,
6818            font_style,
6819            letter_spacing,
6820            variation.map(AsRef::as_ref),
6821        ));
6822        self.legacy_shaped_text_cache.insert(
6823            key,
6824            TextShapeCacheEntry {
6825                glyphs: glyphs.clone(),
6826                last_used_frame: self.frame_index,
6827            },
6828        );
6829        while self.legacy_shaped_text_cache.len() > MAX_LEGACY_SHAPED_TEXT_CACHE_ENTRIES {
6830            let Some(victim) = self
6831                .legacy_shaped_text_cache
6832                .iter()
6833                .min_by_key(|(_, entry)| entry.last_used_frame)
6834                .map(|(key, _)| key.clone())
6835            else {
6836                break;
6837            };
6838            self.legacy_shaped_text_cache.remove(&victim);
6839        }
6840        glyphs
6841    }
6842
6843    fn slug_draw_key(
6844        cache_key: &repose_text::CacheKey,
6845        fill: bool,
6846        stroke_key: Option<&slug::StrokeTessKey>,
6847        transform: &Transform,
6848        origin: (f32, f32),
6849        px: f32,
6850        target: (f32, f32),
6851        color: [f32; 4],
6852    ) -> SlugDrawKey {
6853        let linear = transform.linear();
6854        SlugDrawKey {
6855            cache_key: cache_key.clone(),
6856            fill,
6857            stroke: stroke_key.cloned(),
6858            transform: [
6859                linear[0].to_bits(),
6860                linear[1].to_bits(),
6861                linear[2].to_bits(),
6862                linear[3].to_bits(),
6863                transform.scale_x.to_bits(),
6864                transform.scale_y.to_bits(),
6865                transform.translate_x.to_bits(),
6866                transform.translate_y.to_bits(),
6867                transform.rotate.to_bits(),
6868                transform.shear_x.to_bits(),
6869                transform.shear_y.to_bits(),
6870            ],
6871            origin: [origin.0.to_bits(), origin.1.to_bits()],
6872            px_bits: px.to_bits(),
6873            target: [target.0.to_bits(), target.1.to_bits()],
6874            color: color.map(f32::to_bits),
6875        }
6876    }
6877
6878    fn evict_slug_draw_cache(&mut self) -> bool {
6879        let victim = self
6880            .slug_draw_cache
6881            .iter()
6882            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
6883            .min_by_key(|(_, entry)| entry.last_touch_frame)
6884            .map(|(key, _)| key.clone());
6885        let Some(victim) = victim else {
6886            return false;
6887        };
6888        let Some(entry) = self.slug_draw_cache.remove(&victim) else {
6889            return false;
6890        };
6891        self.slug_draw_cache_bytes_total =
6892            self.slug_draw_cache_bytes_total.saturating_sub(entry.bytes);
6893        true
6894    }
6895
6896    fn reserve_slug_draw_cache(&mut self, bytes: u64) -> bool {
6897        while self.slug_draw_cache.len() >= MAX_SLUG_DRAW_CACHE_ENTRIES
6898            || self.slug_draw_cache_bytes_total.saturating_add(bytes) > MAX_SLUG_DRAW_CACHE_BYTES
6899        {
6900            if !self.evict_slug_draw_cache() {
6901                return false;
6902            }
6903        }
6904        true
6905    }
6906
6907    fn get_cached_slug_vertices(&mut self, key: &SlugDrawKey) -> Option<Arc<[slug::TessVertex]>> {
6908        let touch = self.next_cache_touch();
6909        self.slug_draw_cache.get_mut(key).map(|entry| {
6910            entry.last_used_frame = self.frame_index;
6911            entry.last_touch_frame = touch;
6912            entry.vertices.clone()
6913        })
6914    }
6915
6916    fn cached_slug_vertices(
6917        &mut self,
6918        key: SlugDrawKey,
6919        vertices: Vec<slug::TessVertex>,
6920    ) -> Arc<[slug::TessVertex]> {
6921        let touch = self.next_cache_touch();
6922        if let Some(entry) = self.slug_draw_cache.get_mut(&key) {
6923            entry.last_used_frame = self.frame_index;
6924            entry.last_touch_frame = touch;
6925            return entry.vertices.clone();
6926        }
6927        let bytes = slug_draw_cache_entry_bytes(vertices.len());
6928        if bytes > MAX_SLUG_DRAW_CACHE_BYTES || !self.reserve_slug_draw_cache(bytes) {
6929            return Arc::from(vertices);
6930        }
6931        let touch = self.next_cache_touch();
6932        let vertices: Arc<[slug::TessVertex]> = Arc::from(vertices);
6933        self.slug_draw_cache.insert(
6934            key,
6935            SlugDrawCacheEntry {
6936                vertices: vertices.clone(),
6937                bytes,
6938                last_used_frame: self.frame_index,
6939                last_touch_frame: touch,
6940            },
6941        );
6942        self.slug_draw_cache_bytes_total = self.slug_draw_cache_bytes_total.saturating_add(bytes);
6943        vertices
6944    }
6945
6946    fn cached_glyph_outline(
6947        &mut self,
6948        cache_key: &repose_text::CacheKey,
6949    ) -> Option<Arc<[repose_text::Command]>> {
6950        let touch = self.next_cache_touch();
6951        if let Some(entry) = self.glyph_outline_cache.get_mut(cache_key) {
6952            entry.last_touch = touch;
6953            return entry.commands.clone();
6954        }
6955        let commands = repose_text::extract_outline_commands(cache_key).map(Arc::from);
6956        if self.glyph_outline_cache.len() >= MAX_GLYPH_OUTLINE_CACHE_ENTRIES {
6957            self.trim_glyph_outline_cache();
6958        }
6959        self.glyph_outline_cache.insert(
6960            cache_key.clone(),
6961            GlyphOutlineEntry {
6962                commands: commands.clone(),
6963                last_touch: touch,
6964            },
6965        );
6966        commands
6967    }
6968
6969    /// Drop the coldest outlines so a working set larger than the cap degrades
6970    /// into steady churn instead of a full rebuild on every miss.
6971    fn trim_glyph_outline_cache(&mut self) {
6972        let target = (MAX_GLYPH_OUTLINE_CACHE_ENTRIES / ATLAS_COMPACT_KEEP_DEN as usize)
6973            * ATLAS_COMPACT_KEEP_NUM as usize;
6974        let mut victims: Vec<(u64, repose_text::CacheKey)> = self
6975            .glyph_outline_cache
6976            .iter()
6977            .map(|(key, entry)| (entry.last_touch, key.clone()))
6978            .collect();
6979        victims.sort_unstable_by_key(|victim| victim.0);
6980        for (_, key) in victims {
6981            if self.glyph_outline_cache.len() <= target {
6982                break;
6983            }
6984            self.glyph_outline_cache.remove(&key);
6985        }
6986    }
6987
6988    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
6989        self.atlas_mask_bind.clone()
6990    }
6991
6992    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
6993        self.atlas_color_bind.clone()
6994    }
6995
6996    fn mark_atlas_mask_failure(&mut self, key: repose_text::CacheKey) {
6997        if self.atlas_mask_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
6998            self.atlas_mask_failures.clear();
6999        }
7000        self.atlas_mask_failures.insert(key);
7001    }
7002
7003    fn mark_atlas_color_failure(&mut self, key: repose_text::CacheKey) {
7004        if self.atlas_color_failures.len() >= MAX_GLYPH_ATLAS_ENTRIES {
7005            self.atlas_color_failures.clear();
7006        }
7007        self.atlas_color_failures.insert(key);
7008    }
7009
7010    fn upload_glyph_mask(
7011        &mut self,
7012        cache_key: repose_text::CacheKey,
7013        px: f32,
7014    ) -> Option<GlyphInfo> {
7015        let px = quantize_glyph_px(px);
7016        let keyp = repose_text::CacheKey {
7017            font_size_bits: px.to_bits(),
7018            ..cache_key
7019        };
7020        let touch = self.next_cache_touch();
7021        if let Some(info) = self.atlas_mask.map.get_mut(&keyp) {
7022            info.last_touch = touch;
7023            info.last_used_frame = self.frame_index;
7024            return Some(*info);
7025        }
7026        if self.atlas_mask_failures.contains(&keyp) || self.atlas_mask_deferred.contains(&keyp) {
7027            return None;
7028        }
7029
7030        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7031            self.mark_atlas_mask_failure(keyp);
7032            return None;
7033        };
7034        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
7035            self.mark_atlas_mask_failure(keyp);
7036            return None;
7037        }
7038        let Some(coverage) = swash_to_a8_coverage(gb.content, &gb.data) else {
7039            self.mark_atlas_mask_failure(keyp);
7040            return None;
7041        };
7042
7043        let w = gb.w.max(1);
7044        let h = gb.h.max(1);
7045        let expected = checked_image_bytes_usize(w, h, 1).ok()?;
7046        if coverage.len() < expected {
7047            self.mark_atlas_mask_failure(keyp);
7048            return None;
7049        }
7050        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7051            self.mark_atlas_mask_failure(keyp);
7052            return None;
7053        }
7054        if !self.alloc_or_schedule_mask(w, h) {
7055            defer_atlas_glyph(&mut self.atlas_mask_deferred, keyp);
7056            return None;
7057        }
7058        let x = self.atlas_mask.next_x;
7059        let y = self.atlas_mask.next_y;
7060        self.atlas_mask.next_x += w + 1;
7061        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
7062
7063        let layout = wgpu::TexelCopyBufferLayout {
7064            offset: 0,
7065            bytes_per_row: Some(w),
7066            rows_per_image: Some(h),
7067        };
7068        let size = wgpu::Extent3d {
7069            width: w,
7070            height: h,
7071            depth_or_array_layers: 1,
7072        };
7073        self.queue.write_texture(
7074            wgpu::TexelCopyTextureInfoBase {
7075                texture: &self.atlas_mask.tex,
7076                mip_level: 0,
7077                origin: wgpu::Origin3d { x, y, z: 0 },
7078                aspect: wgpu::TextureAspect::All,
7079            },
7080            &coverage,
7081            layout,
7082            size,
7083        );
7084
7085        let info = GlyphInfo {
7086            u0: x as f32 / self.atlas_mask.size as f32,
7087            v0: y as f32 / self.atlas_mask.size as f32,
7088            u1: (x + w) as f32 / self.atlas_mask.size as f32,
7089            v1: (y + h) as f32 / self.atlas_mask.size as f32,
7090            w: w as f32,
7091            h: h as f32,
7092            last_touch: touch,
7093            last_used_frame: self.frame_index,
7094        };
7095        self.atlas_mask.used_area = self
7096            .atlas_mask
7097            .used_area
7098            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7099        self.atlas_mask.map.insert(keyp, info);
7100        Some(info)
7101    }
7102
7103    fn upload_glyph_color(
7104        &mut self,
7105        cache_key: repose_text::CacheKey,
7106        px: f32,
7107    ) -> Option<GlyphInfo> {
7108        let px = quantize_glyph_px(px);
7109        let keyp = repose_text::CacheKey {
7110            font_size_bits: px.to_bits(),
7111            ..cache_key
7112        };
7113        let touch = self.next_cache_touch();
7114        if let Some(info) = self.atlas_color.map.get_mut(&keyp) {
7115            info.last_touch = touch;
7116            info.last_used_frame = self.frame_index;
7117            return Some(*info);
7118        }
7119        if self.atlas_color_failures.contains(&keyp) || self.atlas_color_deferred.contains(&keyp) {
7120            return None;
7121        }
7122        let Some(gb) = repose_text::rasterize_cache_key(&keyp) else {
7123            self.mark_atlas_color_failure(keyp);
7124            return None;
7125        };
7126        if !matches!(gb.content, repose_text::SwashContent::Color) {
7127            self.mark_atlas_color_failure(keyp);
7128            return None;
7129        }
7130        let w = gb.w.max(1);
7131        let h = gb.h.max(1);
7132        let expected = checked_image_bytes_usize(w, h, 4).ok()?;
7133        if gb.data.len() < expected {
7134            self.mark_atlas_color_failure(keyp);
7135            return None;
7136        }
7137        if w >= MAX_GLYPH_ATLAS_SIZE || h >= MAX_GLYPH_ATLAS_SIZE {
7138            self.mark_atlas_color_failure(keyp);
7139            return None;
7140        }
7141        if !self.alloc_or_schedule_color(w, h) {
7142            defer_atlas_glyph(&mut self.atlas_color_deferred, keyp);
7143            return None;
7144        }
7145        let x = self.atlas_color.next_x;
7146        let y = self.atlas_color.next_y;
7147        self.atlas_color.next_x += w + 1;
7148        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
7149
7150        let layout = wgpu::TexelCopyBufferLayout {
7151            offset: 0,
7152            bytes_per_row: Some(w * 4),
7153            rows_per_image: Some(h),
7154        };
7155        let size = wgpu::Extent3d {
7156            width: w,
7157            height: h,
7158            depth_or_array_layers: 1,
7159        };
7160        self.queue.write_texture(
7161            wgpu::TexelCopyTextureInfoBase {
7162                texture: &self.atlas_color.tex,
7163                mip_level: 0,
7164                origin: wgpu::Origin3d { x, y, z: 0 },
7165                aspect: wgpu::TextureAspect::All,
7166            },
7167            &gb.data,
7168            layout,
7169            size,
7170        );
7171        let info = GlyphInfo {
7172            u0: x as f32 / self.atlas_color.size as f32,
7173            v0: y as f32 / self.atlas_color.size as f32,
7174            u1: (x + w) as f32 / self.atlas_color.size as f32,
7175            v1: (y + h) as f32 / self.atlas_color.size as f32,
7176            w: w as f32,
7177            h: h as f32,
7178            last_touch: touch,
7179            last_used_frame: self.frame_index,
7180        };
7181        self.atlas_color.used_area = self
7182            .atlas_color
7183            .used_area
7184            .saturating_add(u64::from(w).saturating_mul(u64::from(h)));
7185        self.atlas_color.map.insert(keyp, info);
7186        Some(info)
7187    }
7188
7189    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
7190        let Some(x_end) = self
7191            .atlas_mask
7192            .next_x
7193            .checked_add(w)
7194            .and_then(|v| v.checked_add(1))
7195        else {
7196            return false;
7197        };
7198        if x_end >= self.atlas_mask.size {
7199            self.atlas_mask.next_x = 1;
7200            let Some(next_y) = self
7201                .atlas_mask
7202                .next_y
7203                .checked_add(self.atlas_mask.row_h)
7204                .and_then(|v| v.checked_add(1))
7205            else {
7206                return false;
7207            };
7208            self.atlas_mask.next_y = next_y;
7209            self.atlas_mask.row_h = 0;
7210        }
7211        let Some(y_end) = self
7212            .atlas_mask
7213            .next_y
7214            .checked_add(h)
7215            .and_then(|v| v.checked_add(1))
7216        else {
7217            return false;
7218        };
7219        y_end < self.atlas_mask.size
7220    }
7221
7222    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
7223        let Some(x_end) = self
7224            .atlas_color
7225            .next_x
7226            .checked_add(w)
7227            .and_then(|v| v.checked_add(1))
7228        else {
7229            return false;
7230        };
7231        if x_end >= self.atlas_color.size {
7232            self.atlas_color.next_x = 1;
7233            let Some(next_y) = self
7234                .atlas_color
7235                .next_y
7236                .checked_add(self.atlas_color.row_h)
7237                .and_then(|v| v.checked_add(1))
7238            else {
7239                return false;
7240            };
7241            self.atlas_color.next_y = next_y;
7242            self.atlas_color.row_h = 0;
7243        }
7244        let Some(y_end) = self
7245            .atlas_color
7246            .next_y
7247            .checked_add(h)
7248            .and_then(|v| v.checked_add(1))
7249        else {
7250            return false;
7251        };
7252        y_end < self.atlas_color.size
7253    }
7254
7255    /// Place a `w` x `h` cell in the mask atlas, or schedule the work needed to
7256    /// make room. Growth and eviction both re-pack the atlas, which would move
7257    /// glyphs already batched into the frame in flight, so they are deferred to
7258    /// the next `begin_frame`; the caller treats a `false` as "skip this glyph".
7259    fn alloc_or_schedule_mask(&mut self, w: u32, h: u32) -> bool {
7260        if self.atlas_mask.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7261            || self
7262                .atlas_mask
7263                .used_area
7264                .saturating_add(u64::from(w) * u64::from(h))
7265                >= self.atlas_mask.size as u64 * self.atlas_mask.size as u64
7266        {
7267            self.atlas_mask_pending.compact = true;
7268            return false;
7269        }
7270        if self.alloc_space_mask(w, h) {
7271            return true;
7272        }
7273        if self.atlas_mask.size < MAX_GLYPH_ATLAS_SIZE {
7274            self.atlas_mask_pending.grow = true;
7275        } else {
7276            self.atlas_mask_pending.compact = true;
7277        }
7278        false
7279    }
7280
7281    /// See [`Self::alloc_or_schedule_mask`].
7282    fn alloc_or_schedule_color(&mut self, w: u32, h: u32) -> bool {
7283        if self.atlas_color.map.len() >= MAX_GLYPH_ATLAS_ENTRIES
7284            || self
7285                .atlas_color
7286                .used_area
7287                .saturating_add(u64::from(w) * u64::from(h))
7288                >= self.atlas_color.size as u64 * self.atlas_color.size as u64
7289        {
7290            self.atlas_color_pending.compact = true;
7291            return false;
7292        }
7293        if self.alloc_space_color(w, h) {
7294            return true;
7295        }
7296        if self.atlas_color.size < MAX_GLYPH_ATLAS_SIZE {
7297            self.atlas_color_pending.grow = true;
7298        } else {
7299            self.atlas_color_pending.compact = true;
7300        }
7301        false
7302    }
7303}
7304
7305/// Pack the shape brush enums into the single `bits` word the shaders read, so
7306/// they cost one flat varying instead of three locations.
7307fn pack_shape_bits(brush_type: u32, grad_kind: u32, tile_mode: u32, cap: u32) -> u32 {
7308    debug_assert!(brush_type < 4 && grad_kind < 4 && tile_mode < 4 && cap < 4);
7309    (brush_type & 3) | ((grad_kind & 3) << 2) | ((tile_mode & 3) << 4) | ((cap & 3) << 6)
7310}
7311
7312/// Packed brush fields shared by the shape instances (border, ellipse,
7313/// ellipse border, arc). Gradient endpoints are shape-local px; the shaders
7314/// recenter `(0,0)` at the shape top-left. Radial packs `center` into
7315/// `grad_p0` and `radius` into `grad_p1.x`; sweep packs `center` into
7316/// `grad_p0`.
7317///
7318/// `rect` is the shape's scene-space bounds and `transform` the accumulated
7319/// scene transform.
7320/// Endpoints are converted from shape-local px through the inverse linear
7321/// part so rotation and uniform scale cancel against the shader's
7322/// un-rotation. Non-uniform scale and shear distort the gradient the same
7323/// way they distort the shape (the shader un-rotates but cannot un-scale
7324/// pixels).
7325fn brush_to_shape_fields(
7326    brush: &Brush,
7327    rect: &repose_core::Rect,
7328    transform: &Transform,
7329) -> (u32, u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2], u32) {
7330    let to_local = |p: Vec2| {
7331        let m = transform.linear();
7332        let det = m[0] * m[3] - m[1] * m[2];
7333        if det.abs() < 1e-12 {
7334            return [p.x, p.y];
7335        }
7336        [
7337            (m[3] * p.x - m[1] * p.y) / det,
7338            (-m[2] * p.x + m[0] * p.y) / det,
7339        ]
7340    };
7341    match brush {
7342        Brush::Solid(c) => (
7343            0u32,
7344            0u32,
7345            c.to_linear(),
7346            [0.0; 4],
7347            [0.0; 2],
7348            [0.0; 2],
7349            0u32,
7350        ),
7351        Brush::Linear {
7352            start,
7353            end,
7354            start_color,
7355            end_color,
7356        } => (
7357            1u32,
7358            0u32,
7359            start_color.to_linear(),
7360            end_color.to_linear(),
7361            to_local(*start),
7362            to_local(*end),
7363            0u32,
7364        ),
7365        Brush::LinearNormalized {
7366            start,
7367            end,
7368            start_color,
7369            end_color,
7370        } => (
7371            1u32,
7372            0u32,
7373            start_color.to_linear(),
7374            end_color.to_linear(),
7375            to_local(Vec2 {
7376                x: start.x * rect.w,
7377                y: start.y * rect.h,
7378            }),
7379            to_local(Vec2 {
7380                x: end.x * rect.w,
7381                y: end.y * rect.h,
7382            }),
7383            0u32,
7384        ),
7385        Brush::Radial {
7386            center,
7387            radius,
7388            start_color,
7389            end_color,
7390        } => (
7391            1u32,
7392            1u32,
7393            start_color.to_linear(),
7394            end_color.to_linear(),
7395            to_local(*center),
7396            [radius.max(0.0), 0.0],
7397            0u32,
7398        ),
7399        Brush::Sweep {
7400            center,
7401            start_color,
7402            end_color,
7403        } => (
7404            1u32,
7405            2u32,
7406            start_color.to_linear(),
7407            end_color.to_linear(),
7408            to_local(*center),
7409            [0.0, 0.0],
7410            0u32,
7411        ),
7412        _ => (0u32, 0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2], 0u32),
7413    }
7414}
7415
7416fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
7417    match brush {
7418        Brush::Solid(c) => (
7419            0u32,
7420            c.to_linear(),
7421            [0.0, 0.0, 0.0, 0.0],
7422            [0.0, 0.0],
7423            [0.0, 1.0],
7424        ),
7425        Brush::Linear {
7426            start,
7427            end,
7428            start_color,
7429            end_color,
7430        } => (
7431            1u32,
7432            start_color.to_linear(),
7433            end_color.to_linear(),
7434            [start.x, start.y],
7435            [end.x, end.y],
7436        ),
7437        Brush::LinearNormalized {
7438            start,
7439            end,
7440            start_color,
7441            end_color,
7442        } => (
7443            1u32,
7444            start_color.to_linear(),
7445            end_color.to_linear(),
7446            [start.x, start.y],
7447            [end.x, end.y],
7448        ),
7449        Brush::Radial { start_color, .. } => (
7450            0u32,
7451            start_color.to_linear(),
7452            [0.0, 0.0, 0.0, 0.0],
7453            [0.0, 0.0],
7454            [0.0, 1.0],
7455        ),
7456        Brush::Sweep { 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        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
7464    }
7465}
7466
7467/// Fallback color when a [`Brush`] reaches a solid-only path (glyph atlas
7468/// uploads for gradient text). Uses the gradient's start color.
7469#[allow(dead_code)]
7470fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
7471    match brush {
7472        Brush::Solid(c) => c.to_linear(),
7473        Brush::Linear { start_color, .. } => start_color.to_linear(),
7474        Brush::LinearNormalized { start_color, .. } => start_color.to_linear(),
7475        Brush::Radial { start_color, .. } => start_color.to_linear(),
7476        Brush::Sweep { start_color, .. } => start_color.to_linear(),
7477        _ => [0.0; 4],
7478    }
7479}
7480
7481fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
7482    let size = 1024u32;
7483    let tex = device.create_texture(&wgpu::TextureDescriptor {
7484        label: Some("glyph atlas A8"),
7485        size: wgpu::Extent3d {
7486            width: size,
7487            height: size,
7488            depth_or_array_layers: 1,
7489        },
7490        mip_level_count: 1,
7491        sample_count: 1,
7492        dimension: wgpu::TextureDimension::D2,
7493        format: wgpu::TextureFormat::R8Unorm,
7494        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7495        view_formats: &[],
7496    });
7497    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7498    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7499        label: Some("glyph atlas sampler A8"),
7500        address_mode_u: wgpu::AddressMode::ClampToEdge,
7501        address_mode_v: wgpu::AddressMode::ClampToEdge,
7502        address_mode_w: wgpu::AddressMode::ClampToEdge,
7503        mag_filter: wgpu::FilterMode::Linear,
7504        min_filter: wgpu::FilterMode::Linear,
7505        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7506        ..Default::default()
7507    });
7508
7509    AtlasA8 {
7510        tex,
7511        view,
7512        sampler,
7513        size,
7514        next_x: 1,
7515        next_y: 1,
7516        row_h: 0,
7517        map: HashMap::new(),
7518        used_area: 0,
7519    }
7520}
7521
7522fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
7523    let size = 1024u32;
7524    let tex = device.create_texture(&wgpu::TextureDescriptor {
7525        label: Some("glyph atlas RGBA"),
7526        size: wgpu::Extent3d {
7527            width: size,
7528            height: size,
7529            depth_or_array_layers: 1,
7530        },
7531        mip_level_count: 1,
7532        sample_count: 1,
7533        dimension: wgpu::TextureDimension::D2,
7534        format: wgpu::TextureFormat::Rgba8UnormSrgb,
7535        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
7536        view_formats: &[],
7537    });
7538    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
7539    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
7540        label: Some("glyph atlas sampler RGBA"),
7541        address_mode_u: wgpu::AddressMode::ClampToEdge,
7542        address_mode_v: wgpu::AddressMode::ClampToEdge,
7543        address_mode_w: wgpu::AddressMode::ClampToEdge,
7544        mag_filter: wgpu::FilterMode::Linear,
7545        min_filter: wgpu::FilterMode::Linear,
7546        mipmap_filter: wgpu::MipmapFilterMode::Linear,
7547        ..Default::default()
7548    });
7549    AtlasRGBA {
7550        tex,
7551        view,
7552        sampler,
7553        size,
7554        next_x: 1,
7555        next_y: 1,
7556        row_h: 0,
7557        map: HashMap::new(),
7558        used_area: 0,
7559    }
7560}
7561
7562#[cfg(feature = "winit-surface")]
7563impl WgpuSurfaceBackend {
7564    /// Drop the surface, keeping device/queue/pipelines. Releases the old
7565    /// native-window binding. Call `recreate_surface` to present again.
7566    pub fn take_surface(&mut self) -> Option<wgpu::Surface<'static>> {
7567        self.surface.take()
7568    }
7569
7570    /// Create a fresh surface for `window` on the retained instance and
7571    /// configure it. Recovers from `CurrentSurfaceTexture::Lost`, where
7572    /// reconfiguring the old surface object cannot help.
7573    pub fn recreate_surface(&mut self, window: &Arc<winit::window::Window>) -> anyhow::Result<()> {
7574        self.window = Some(window.clone());
7575        let size = window.inner_size();
7576        if size.width == 0 || size.height == 0 {
7577            self.surface = None;
7578            self.pending_reconfigure = false;
7579            self.renderer.resize(0, 0);
7580            anyhow::bail!("window has zero size; surface recreation deferred");
7581        }
7582        let Some(instance) = self.instance.as_ref() else {
7583            anyhow::bail!("no wgpu instance retained; cannot recreate surface")
7584        };
7585        let Some(config) = self.surface_config.as_mut() else {
7586            anyhow::bail!("no surface config retained; cannot recreate surface")
7587        };
7588        let (width, height) = clamp_output_size(
7589            size.width,
7590            size.height,
7591            self.renderer.device.limits().max_texture_dimension_2d,
7592        );
7593        config.width = width;
7594        config.height = height;
7595        let surface = instance.create_surface(window.clone())?;
7596        surface.configure(&self.renderer.device, config);
7597        self.surface = Some(surface);
7598        self.pending_reconfigure = false;
7599        self.renderer.resize(width, height);
7600        Ok(())
7601    }
7602}
7603
7604#[cfg(feature = "winit-surface")]
7605impl RenderBackend for WgpuSurfaceBackend {
7606    fn configure_surface(&mut self, width: u32, height: u32) -> (u32, u32) {
7607        if width == 0 || height == 0 {
7608            self.renderer.resize(0, 0);
7609            self.pending_reconfigure = true;
7610            return (0, 0);
7611        }
7612        let (width, height) = clamp_output_size(
7613            width,
7614            height,
7615            self.renderer.device.limits().max_texture_dimension_2d,
7616        );
7617        if self.renderer.output_width == width && self.renderer.output_height == height {
7618            if let Some(ref mut config) = self.surface_config {
7619                config.width = width;
7620                config.height = height;
7621            }
7622            return (width, height);
7623        }
7624        if let Some(ref mut config) = self.surface_config {
7625            config.width = width;
7626            config.height = height;
7627        }
7628        self.renderer.resize(width, height);
7629        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref())
7630        {
7631            surface.configure(&self.renderer.device, config);
7632        }
7633        (width, height)
7634    }
7635
7636    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) -> bool {
7637        if self.pending_reconfigure {
7638            if let (Some(surface), Some(config)) =
7639                (self.surface.as_ref(), self.surface_config.as_ref())
7640            {
7641                surface.configure(&self.renderer.device, config);
7642            }
7643            self.pending_reconfigure = false;
7644        }
7645
7646        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
7647            request_frame();
7648            return false;
7649        }
7650        if let Err(error) = self.renderer.begin_frame() {
7651            log::warn!("begin renderer frame: {error:#}");
7652            request_frame();
7653            return false;
7654        }
7655
7656        let Some(surface) = self.surface.as_ref() else {
7657            self.renderer.end_frame();
7658            request_frame();
7659            return false;
7660        };
7661        let frame = match surface.get_current_texture() {
7662            wgpu::CurrentSurfaceTexture::Success(f) => f,
7663            wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
7664                self.pending_reconfigure = true;
7665                f
7666            }
7667            other @ (wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost) => {
7668                self.surface = None;
7669                let result = self
7670                    .window
7671                    .clone()
7672                    .ok_or_else(|| anyhow::anyhow!("no window retained for surface recreation"))
7673                    .and_then(|window| self.recreate_surface(&window));
7674                if let Err(error) = result {
7675                    log::warn!("surface {other:?}; recreation deferred: {error:#}");
7676                }
7677                self.renderer.end_frame();
7678                request_frame();
7679                return false;
7680            }
7681            other => {
7682                match other {
7683                    wgpu::CurrentSurfaceTexture::Validation => {
7684                        log::warn!("surface {other:?}; reconfiguring next frame");
7685                        self.pending_reconfigure = true;
7686                    }
7687                    wgpu::CurrentSurfaceTexture::Timeout
7688                    | wgpu::CurrentSurfaceTexture::Occluded => {
7689                        log::debug!("surface {other:?}; retrying next frame");
7690                    }
7691                    _ => {}
7692                }
7693                self.renderer.end_frame();
7694                request_frame();
7695                return false;
7696            }
7697        };
7698
7699        // The swapchain extent and the renderer's size are updated from
7700        // separate events, so they can disagree for a frame mid-resize (most
7701        // visibly when dragging a window into a portrait shape). With MSAA on,
7702        // a color attachment whose extent differs from its resolve target
7703        // fails wgpu validation, which drops the whole pass *including its
7704        // clear*, so the just-reconfigured swapchain keeps undefined contents
7705        // and presents as green/black streaks. Adopt the swapchain's size and
7706        // retry next frame instead of rendering an invalid pass.
7707        let (swap_w, swap_h) = (frame.texture.width(), frame.texture.height());
7708        if swap_w != self.renderer.output_width || swap_h != self.renderer.output_height {
7709            log::debug!(
7710                "swapchain {swap_w}x{swap_h} != renderer {}x{}; resyncing",
7711                self.renderer.output_width,
7712                self.renderer.output_height
7713            );
7714            drop(frame);
7715            if let Some(config) = self.surface_config.as_mut() {
7716                config.width = swap_w;
7717                config.height = swap_h;
7718            }
7719            self.renderer.resize(swap_w, swap_h);
7720            self.pending_reconfigure = true;
7721            self.renderer.end_frame();
7722            request_frame();
7723            return false;
7724        }
7725
7726        let swap_view = if let Some(view_format) = self
7727            .surface_config
7728            .as_ref()
7729            .and_then(|c| c.view_formats.iter().find(|f| f.is_srgb()).copied())
7730        {
7731            frame.texture.create_view(&wgpu::TextureViewDescriptor {
7732                format: Some(view_format),
7733                ..Default::default()
7734            })
7735        } else {
7736            frame
7737                .texture
7738                .create_view(&wgpu::TextureViewDescriptor::default())
7739        };
7740        let mut encoder =
7741            self.renderer
7742                .device
7743                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7744                    label: Some("frame encoder"),
7745                });
7746
7747        self.renderer.render_scene_to_encoder_with_texture(
7748            scene,
7749            &mut encoder,
7750            &swap_view,
7751            Some(&frame.texture),
7752            None,
7753        );
7754
7755        //NOTE: The WebGL HAL present path (fullscreen triangle / blit) does not
7756        // restore gl.colorMask. Hence this is needed to prevent frames from going transparent.
7757        {
7758            let _reset = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
7759                label: Some("webgl color_mask reset before present"),
7760                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7761                    view: &swap_view,
7762                    resolve_target: None,
7763                    ops: wgpu::Operations {
7764                        load: wgpu::LoadOp::Load,
7765                        store: wgpu::StoreOp::Store,
7766                    },
7767                    depth_slice: None,
7768                })],
7769                depth_stencil_attachment: None,
7770                timestamp_writes: None,
7771                occlusion_query_set: None,
7772                multiview_mask: None,
7773            });
7774        }
7775
7776        self.renderer
7777            .queue
7778            .submit(std::iter::once(encoder.finish()));
7779        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
7780            log::warn!("queue.present panicked: {:?}", e);
7781            self.renderer.end_frame();
7782            request_frame();
7783            return false;
7784        }
7785        self.renderer.end_frame();
7786        true
7787    }
7788}
7789
7790impl WgpuSceneRenderer {
7791    /// Open a translator-owned flatten layer for a perspective `PushTransform`.
7792    ///
7793    /// True perspective cannot ride the affine instance fast path, so the
7794    /// subtree renders flat into an offscreen layer and is composited back
7795    /// projectively on the matching pop (CSS-style flattening). The layer
7796    /// rect is the currently visible scissor in this target: content outside
7797    /// it is invisible in the parent, so clipping it in the layer changes
7798    /// nothing. Children keep the node's affine part on the stack (so
7799    /// `combine` stays affine-only) plus a layer-local shift, exactly like
7800    /// producer-owned blur layers - which is exact under rigid ancestors
7801    /// (translations commute) and the documented layer contract otherwise.
7802    #[allow(clippy::too_many_arguments)]
7803    fn replay_active_clips(
7804        &mut self,
7805        clips: &[ActiveClip],
7806        parent_transform: &Transform,
7807        origin: (f32, f32),
7808        target_size: (f32, f32),
7809        pass: &mut Pass,
7810        encoder: &mut wgpu::CommandEncoder,
7811    ) -> Vec<ActiveClip> {
7812        let mut current = repose_core::Rect {
7813            x: 0.0,
7814            y: 0.0,
7815            w: target_size.0,
7816            h: target_size.1,
7817        };
7818        let mut replayed = Vec::with_capacity(clips.len());
7819        for (index, clip) in clips.iter().enumerate() {
7820            let has_inverse = clips[..index].iter().any(ActiveClip::difference);
7821            let mut blocked = clip.blocked();
7822            if has_inverse && !blocked {
7823                log::error!(
7824                    "unsupported clip nesting: a Difference clip cannot contain another clip"
7825                );
7826                blocked = true;
7827            }
7828            let difference = clip.difference();
7829            let mut command = None;
7830            match clip {
7831                ActiveClip::Rect {
7832                    off: _,
7833                    cnt: _,
7834                    rect,
7835                    radii,
7836                    ..
7837                } => {
7838                    let local_rect = parent_rect_to_layer_rect(*rect, parent_transform, origin)
7839                        .unwrap_or(repose_core::Rect {
7840                            x: 0.0,
7841                            y: 0.0,
7842                            w: 0.0,
7843                            h: 0.0,
7844                        });
7845                    let local_radii =
7846                        parent_radii_to_layer_radii(*radii, parent_transform).unwrap_or([0.0; 4]);
7847                    let next = if difference {
7848                        current
7849                    } else {
7850                        intersect(current, local_rect)
7851                    };
7852                    let scissor = rect_to_scissor(
7853                        next,
7854                        target_size.0.max(1.0) as u32,
7855                        target_size.1.max(1.0) as u32,
7856                    );
7857                    let has_area = local_rect.x.is_finite()
7858                        && local_rect.y.is_finite()
7859                        && local_rect.w.is_finite()
7860                        && local_rect.h.is_finite()
7861                        && local_rect.w > 0.0
7862                        && local_rect.h > 0.0;
7863                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7864                        blocked = blocked || !difference;
7865                    } else {
7866                        let ndc = rect_to_ndc(local_rect, target_size.0, target_size.1);
7867                        let instance = ClipInstance {
7868                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
7869                            radii: local_radii,
7870                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
7871                        };
7872                        let bytes = bytemuck::bytes_of(&instance);
7873                        if self
7874                            .clip_ring
7875                            .grow_to_fit(
7876                                &self.device,
7877                                encoder,
7878                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
7879                            )
7880                            .is_ok()
7881                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
7882                        {
7883                            command = Some(Cmd::ClipPush {
7884                                off: new_off,
7885                                cnt: 1,
7886                                scissor,
7887                                difference,
7888                                applied: true,
7889                            });
7890                            replayed.push(ActiveClip::Rect {
7891                                off: new_off,
7892                                cnt: 1,
7893                                rect: local_rect,
7894                                radii: local_radii,
7895                                scissor,
7896                                difference,
7897                                applied: true,
7898                                blocked: false,
7899                            });
7900                        } else {
7901                            blocked = blocked || !difference;
7902                        }
7903                    }
7904                    current = next;
7905                }
7906                ActiveClip::Vector {
7907                    mesh,
7908                    source,
7909                    affine,
7910                    ..
7911                } => {
7912                    let local_affine =
7913                        parent_affine_to_layer_affine(*affine, parent_transform, origin)
7914                            .unwrap_or([0.0; 6]);
7915                    let aabb = mesh_aabb(source, local_affine);
7916                    let next = if difference {
7917                        current
7918                    } else {
7919                        intersect(current, aabb)
7920                    };
7921                    let scissor = rect_to_scissor(
7922                        next,
7923                        target_size.0.max(1.0) as u32,
7924                        target_size.1.max(1.0) as u32,
7925                    );
7926                    let has_area = aabb.x.is_finite()
7927                        && aabb.y.is_finite()
7928                        && aabb.w.is_finite()
7929                        && aabb.h.is_finite()
7930                        && aabb.w > 0.0
7931                        && aabb.h > 0.0
7932                        && mesh
7933                            .as_ref()
7934                            .is_some_and(|mesh| mesh.vertex_count > 0 && mesh.index_count > 0);
7935                    if blocked || !has_area || (!difference && scissor.2 == 0) {
7936                        blocked = blocked || !difference;
7937                    } else if let Some(mesh_gpu) = mesh.clone()
7938                        && let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
7939                            &repose_core::PaintDesc::Solid,
7940                        ))
7941                        && let Some((ioff, icnt)) = self.alloc_mesh_instances(&[local_affine])
7942                    {
7943                        command = Some(Cmd::VectorClipPush {
7944                            mesh: mesh_gpu.clone(),
7945                            uoff,
7946                            ioff,
7947                            icnt,
7948                            scissor,
7949                            difference,
7950                            applied: true,
7951                        });
7952                        replayed.push(ActiveClip::Vector {
7953                            mesh: Some(mesh_gpu),
7954                            source: source.clone(),
7955                            uoff,
7956                            ioff,
7957                            icnt,
7958                            affine: local_affine,
7959                            scissor,
7960                            difference,
7961                            applied: true,
7962                            blocked: false,
7963                        });
7964                    } else {
7965                        blocked = blocked || !difference;
7966                    }
7967                    current = next;
7968                }
7969            }
7970            if blocked {
7971                current = repose_core::Rect {
7972                    x: 0.0,
7973                    y: 0.0,
7974                    w: 0.0,
7975                    h: 0.0,
7976                };
7977            }
7978            if command.is_none() {
7979                replayed.push(match clip {
7980                    ActiveClip::Rect {
7981                        rect,
7982                        radii,
7983                        scissor,
7984                        difference,
7985                        ..
7986                    } => ActiveClip::Rect {
7987                        off: 0,
7988                        cnt: 0,
7989                        rect: *rect,
7990                        radii: *radii,
7991                        scissor: *scissor,
7992                        difference: *difference,
7993                        applied: false,
7994                        blocked,
7995                    },
7996                    ActiveClip::Vector {
7997                        mesh,
7998                        source,
7999                        affine,
8000                        scissor,
8001                        difference,
8002                        ..
8003                    } => ActiveClip::Vector {
8004                        mesh: mesh.clone(),
8005                        source: source.clone(),
8006                        uoff: 0,
8007                        ioff: 0,
8008                        icnt: 0,
8009                        affine: parent_affine_to_layer_affine(*affine, parent_transform, origin)
8010                            .unwrap_or([0.0; 6]),
8011                        scissor: *scissor,
8012                        difference: *difference,
8013                        applied: false,
8014                        blocked,
8015                    },
8016                });
8017            }
8018            if let Some(command) = command {
8019                pass.cmds.push(command);
8020            }
8021            pass.active_scissor = if blocked {
8022                None
8023            } else {
8024                let scissor = rect_to_scissor(
8025                    current,
8026                    target_size.0.max(1.0) as u32,
8027                    target_size.1.max(1.0) as u32,
8028                );
8029                (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
8030            };
8031        }
8032        replayed
8033    }
8034
8035    #[allow(clippy::too_many_arguments)]
8036    fn push_perspective_layer(
8037        &mut self,
8038        node: Transform,
8039        top: Transform,
8040        transform_stack: &mut Vec<Transform>,
8041        scissor_stack: &mut Vec<repose_core::Rect>,
8042        root_clip_rect: &mut repose_core::Rect,
8043        current_target_size: &mut (f32, f32),
8044        current_pass: &mut Pass,
8045        active_clips: &mut Vec<ActiveClip>,
8046        passes: &mut Vec<Pass>,
8047        target_stack: &mut Vec<PassTarget>,
8048        flatten_stack: &mut Vec<FlattenRecord>,
8049        id_head: &mut u32,
8050        pass_id_head: &mut u64,
8051        ids_used: &mut Vec<u32>,
8052        encoder: &mut wgpu::CommandEncoder,
8053    ) {
8054        // Full projective map: affine ancestors over the node's map.
8055        // Ancestors are affine by construction (perspective always flattens
8056        // at push, and only stripped affines reach the stack).
8057        let map =
8058            Transform::compose_projective(&top.projective_matrix(), &node.projective_matrix());
8059        let scr = scissor_stack.last().copied().unwrap_or(*root_clip_rect);
8060        let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
8061        if !scr.w.is_finite()
8062            || !scr.h.is_finite()
8063            || scr.w < 1.0
8064            || scr.h < 1.0
8065            || scr.w > max_dimension
8066            || scr.h > max_dimension
8067        {
8068            return;
8069        }
8070        let w = scr.w.ceil();
8071        let h = scr.h.ceil();
8072        let layer_rect = repose_core::Rect {
8073            x: scr.x,
8074            y: scr.y,
8075            w,
8076            h,
8077        };
8078        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8079            return;
8080        }
8081        let layer_id = *id_head;
8082        *id_head = id_head.wrapping_add(1);
8083        ids_used.push(layer_id);
8084
8085        let stack_len = transform_stack.len();
8086        // Children render with the ancestors' map only: the node's own
8087        // affine part lives in `map` and applies once, at composite time.
8088        // (Pushing the stripped affine here too would foreshorten twice.)
8089        transform_stack.push(top);
8090        transform_stack.push(Transform::translate(-layer_rect.x, -layer_rect.y));
8091
8092        let saved_scissor_stack = std::mem::replace(
8093            scissor_stack,
8094            vec![repose_core::Rect {
8095                x: 0.0,
8096                y: 0.0,
8097                w,
8098                h,
8099            }],
8100        );
8101        let saved_root = std::mem::replace(
8102            root_clip_rect,
8103            repose_core::Rect {
8104                x: 0.0,
8105                y: 0.0,
8106                w,
8107                h,
8108            },
8109        );
8110        let saved_size = std::mem::replace(current_target_size, (w, h));
8111        let prev_target = current_pass.target;
8112        let saved_active_scissor = current_pass.active_scissor;
8113        let saved_clips = std::mem::take(active_clips);
8114        let layer_pass_id = *pass_id_head;
8115        *pass_id_head += 1;
8116        let mut layer_pass = Pass {
8117            id: layer_pass_id,
8118            target: PassTarget::Layer(layer_id),
8119            initial_scissor: (0, 0, w as u32, h as u32),
8120            active_scissor: Some((0, 0, w as u32, h as u32)),
8121            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8122            cmds: Vec::new(),
8123        };
8124        let layer_clips = self.replay_active_clips(
8125            &saved_clips,
8126            &top,
8127            (layer_rect.x, layer_rect.y),
8128            (w, h),
8129            &mut layer_pass,
8130            encoder,
8131        );
8132        *active_clips = layer_clips;
8133        let saved = std::mem::replace(current_pass, layer_pass);
8134        passes.push(saved);
8135        target_stack.push(prev_target);
8136        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8137        *current_target_size = (w, h);
8138        flatten_stack.push(FlattenRecord {
8139            stack_len,
8140            layer_id,
8141            map,
8142            layer_rect,
8143            saved_scissor_stack,
8144            saved_root,
8145            saved_size,
8146            saved_active_scissor,
8147            saved_clips,
8148        });
8149    }
8150
8151    /// Close a flatten layer: restore the parent target and composite the
8152    /// layer texture through the recorded projective map.
8153    #[allow(clippy::too_many_arguments)]
8154    fn pop_perspective_layer(
8155        &mut self,
8156        rec: FlattenRecord,
8157        scissor_stack: &mut Vec<repose_core::Rect>,
8158        root_clip_rect: &mut repose_core::Rect,
8159        current_target_size: &mut (f32, f32),
8160        current_pass: &mut Pass,
8161        active_clips: &mut Vec<ActiveClip>,
8162        passes: &mut Vec<Pass>,
8163        target_stack: &mut Vec<PassTarget>,
8164        pass_id_head: &mut u64,
8165        encoder: &mut wgpu::CommandEncoder,
8166    ) {
8167        *scissor_stack = rec.saved_scissor_stack;
8168        *root_clip_rect = rec.saved_root;
8169        *current_target_size = rec.saved_size;
8170        *active_clips = rec.saved_clips;
8171        let resumed_pass_id = *pass_id_head;
8172        *pass_id_head += 1;
8173        let saved = std::mem::replace(
8174            current_pass,
8175            Pass {
8176                id: resumed_pass_id,
8177                target: target_stack.pop().unwrap_or(PassTarget::Surface),
8178                initial_scissor: rec.saved_active_scissor.unwrap_or((0, 0, 1, 1)),
8179                active_scissor: rec.saved_active_scissor,
8180                clear_color: None,
8181                cmds: Vec::new(),
8182            },
8183        );
8184        passes.push(saved);
8185
8186        // Project the layer-rect corners (parent space) to NDC in the
8187        // resumed (parent) target, keeping each corner's homogeneous w for
8188        // perspective-correct sampling.
8189        let (tw, th) = rec.saved_size;
8190        let r = rec.layer_rect;
8191        let corners = [
8192            (r.x, r.y),
8193            (r.x + r.w, r.y),
8194            (r.x + r.w, r.y + r.h),
8195            (r.x, r.y + r.h),
8196        ];
8197        let mut ndc = [[0.0f32; 2]; 4];
8198        let mut ws = [1.0f32; 4];
8199        let mut all_behind = true;
8200        for (i, (x, y)) in corners.iter().enumerate() {
8201            let w_raw = rec.map[6] * x + rec.map[7] * y + rec.map[8];
8202            let w = if w_raw.abs() < 1e-6 {
8203                if w_raw < 0.0 { -1e-6 } else { 1e-6 }
8204            } else {
8205                w_raw
8206            };
8207            if w > 0.0 {
8208                all_behind = false;
8209            }
8210            let px = (rec.map[0] * x + rec.map[1] * y + rec.map[2]) / w;
8211            let py = (rec.map[3] * x + rec.map[4] * y + rec.map[5]) / w;
8212            ndc[i] = [px / tw * 2.0 - 1.0, 1.0 - py / th * 2.0];
8213            ws[i] = w;
8214        }
8215        if all_behind {
8216            // Entire subtree behind the viewer: nothing to composite (the
8217            // layer pass still ran, but its output is correctly discarded).
8218            return;
8219        }
8220        let Some(layer) = self.layer_pool.get(&rec.layer_id) else {
8221            return;
8222        };
8223        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
8224        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
8225        let inst = ProjectiveInstance {
8226            c0: ndc[0],
8227            c1: ndc[1],
8228            c2: ndc[2],
8229            c3: ndc[3],
8230            uv: [0.0, 0.0, uv_u1, uv_v1],
8231            w: ws,
8232            alpha: 1.0,
8233            _pad: [0.0; 3],
8234        };
8235        if self
8236            .projective_ring
8237            .grow_to_fit(
8238                &self.device,
8239                encoder,
8240                std::mem::size_of::<ProjectiveInstance>() as u64,
8241            )
8242            .is_err()
8243        {
8244            return;
8245        }
8246        let bytes = bytemuck::bytes_of(&inst);
8247        let Ok(off) = self.projective_ring.alloc_write(&self.queue, bytes) else {
8248            return;
8249        };
8250        current_pass.cmds.push(Cmd::CompositeProjective {
8251            off,
8252            cnt: 1,
8253            layer_id: rec.layer_id,
8254        });
8255    }
8256
8257    fn mesh_cache_key(mesh: &Arc<repose_core::VectorMeshData>) -> MeshCacheKey {
8258        MeshCacheKey {
8259            source: Arc::as_ptr(mesh) as usize,
8260            index: 0,
8261            kind: 1,
8262            vertex_len: mesh.vertices.len(),
8263            index_len: mesh.indices.len(),
8264        }
8265    }
8266
8267    fn overlay_mesh_cache_key(
8268        meshes: &Arc<[repose_core::VectorMeshData]>,
8269        index: usize,
8270    ) -> MeshCacheKey {
8271        let mesh = &meshes[index];
8272        MeshCacheKey {
8273            source: meshes.as_ptr() as usize,
8274            index,
8275            kind: 2,
8276            vertex_len: mesh.vertices.len(),
8277            index_len: mesh.indices.len(),
8278        }
8279    }
8280
8281    fn mesh_owner_matches(owner: &MeshOwner, other: &MeshOwner) -> bool {
8282        match (owner, other) {
8283            (MeshOwner::Object(a), MeshOwner::Object(b)) => Arc::ptr_eq(a, b),
8284            (MeshOwner::Overlay(a), MeshOwner::Overlay(b)) => Arc::ptr_eq(a, b),
8285            _ => false,
8286        }
8287    }
8288
8289    fn evict_mesh_cache(&mut self) -> bool {
8290        let victim = self
8291            .mesh_cache
8292            .iter()
8293            .filter(|(_, entry)| entry.last_used_frame != self.frame_index)
8294            .min_by_key(|(_, entry)| entry.last_touch_frame)
8295            .map(|(key, _)| *key);
8296        let Some(victim) = victim else {
8297            return false;
8298        };
8299        let Some(entry) = self.mesh_cache.remove(&victim) else {
8300            return false;
8301        };
8302        self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8303        true
8304    }
8305
8306    fn reserve_mesh_cache(&mut self, bytes: u64) -> bool {
8307        while self.mesh_cache.len() >= MAX_MESH_CACHE_ENTRIES
8308            || self.mesh_cache_bytes_total.saturating_add(bytes) > MAX_MESH_CACHE_BYTES
8309        {
8310            if !self.evict_mesh_cache() {
8311                return false;
8312            }
8313        }
8314        true
8315    }
8316
8317    fn reserve_mesh_gpu_budget(&mut self, bytes: u64) -> bool {
8318        while self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes {
8319            if !self.evict_mesh_cache() {
8320                return false;
8321            }
8322        }
8323        true
8324    }
8325
8326    fn upload_mesh_geometry_with_owner(
8327        &mut self,
8328        mesh: &repose_core::VectorMeshData,
8329        key: MeshCacheKey,
8330        owner: MeshOwner,
8331    ) -> Option<MeshGpu> {
8332        let touch = self.next_cache_touch();
8333        let cached = self
8334            .mesh_cache
8335            .get(&key)
8336            .filter(|entry| Self::mesh_owner_matches(&entry.owner, &owner))
8337            .map(|entry| entry.gpu.clone());
8338        if let Some(gpu) = cached {
8339            if let Some(entry) = self.mesh_cache.get_mut(&key) {
8340                entry.last_used_frame = self.frame_index;
8341                entry.last_touch_frame = touch;
8342            }
8343            return Some(gpu);
8344        }
8345        if let Some(entry) = self.mesh_cache.remove(&key) {
8346            self.mesh_cache_bytes_total =
8347                self.mesh_cache_bytes_total.saturating_sub(entry.gpu.bytes);
8348        }
8349        if mesh.vertices.is_empty() || mesh.indices.is_empty() {
8350            log::warn!("vector mesh has no geometry");
8351            return None;
8352        }
8353        if !mesh.indices.len().is_multiple_of(3) {
8354            log::warn!("vector mesh index count is not a triangle list");
8355            return None;
8356        }
8357        let vertex_count = u32::try_from(mesh.vertices.len()).ok()?;
8358        let index_count = u32::try_from(mesh.indices.len()).ok()?;
8359        if mesh.indices.iter().any(|&index| index >= vertex_count) {
8360            log::warn!("vector mesh index is outside its vertex buffer");
8361            return None;
8362        }
8363        if mesh.vertices.iter().any(|vertex| {
8364            vertex.pos.iter().any(|value| !value.is_finite())
8365                || vertex.color.iter().any(|value| !value.is_finite())
8366                || vertex.uv.iter().any(|value| !value.is_finite())
8367        }) {
8368            log::warn!("vector mesh contains non-finite vertex data");
8369            return None;
8370        }
8371        let verts: Vec<MeshVertex> = mesh
8372            .vertices
8373            .iter()
8374            .map(|v| MeshVertex {
8375                pos: v.pos,
8376                color: v.color,
8377                uv: v.uv,
8378            })
8379            .collect();
8380        let vbytes = bytemuck::cast_slice(&verts);
8381        let ibytes = bytemuck::cast_slice(&mesh.indices[..]);
8382        let bytes = u64::try_from(vbytes.len())
8383            .ok()?
8384            .checked_add(u64::try_from(ibytes.len()).ok()?)?;
8385        let max_buffer_size = self.device.limits().max_buffer_size;
8386        if vbytes.len() as u64 > max_buffer_size || ibytes.len() as u64 > max_buffer_size {
8387            return None;
8388        }
8389        let cacheable = bytes <= MAX_MESH_CACHE_BYTES
8390            && self.reserve_mesh_cache(bytes)
8391            && self.reserve_mesh_gpu_budget(bytes);
8392        let vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
8393            label: Some("vector mesh vertices"),
8394            size: vbytes.len().max(4) as u64,
8395            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
8396            mapped_at_creation: false,
8397        });
8398        let indices = self.device.create_buffer(&wgpu::BufferDescriptor {
8399            label: Some("vector mesh indices"),
8400            size: ibytes.len().max(4) as u64,
8401            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
8402            mapped_at_creation: false,
8403        });
8404        self.queue.write_buffer(&vertices, 0, vbytes);
8405        self.queue.write_buffer(&indices, 0, ibytes);
8406        let gpu = MeshGpu {
8407            vertices,
8408            indices,
8409            vertex_count,
8410            index_count,
8411            bytes,
8412        };
8413        if cacheable {
8414            let touch = self.next_cache_touch();
8415            self.mesh_cache.insert(
8416                key,
8417                MeshCacheEntry {
8418                    owner,
8419                    gpu: gpu.clone(),
8420                    last_used_frame: self.frame_index,
8421                    last_touch_frame: touch,
8422                },
8423            );
8424            self.mesh_cache_bytes_total = self.mesh_cache_bytes_total.saturating_add(bytes);
8425        }
8426        Some(gpu)
8427    }
8428
8429    fn upload_mesh_geometry(&mut self, mesh: &Arc<repose_core::VectorMeshData>) -> Option<MeshGpu> {
8430        self.upload_mesh_geometry_with_owner(
8431            mesh,
8432            Self::mesh_cache_key(mesh),
8433            MeshOwner::Object(mesh.clone()),
8434        )
8435    }
8436
8437    fn upload_overlay_mesh(
8438        &mut self,
8439        meshes: &Arc<[repose_core::VectorMeshData]>,
8440        index: usize,
8441    ) -> Option<MeshGpu> {
8442        self.upload_mesh_geometry_with_owner(
8443            &meshes[index],
8444            Self::overlay_mesh_cache_key(meshes, index),
8445            MeshOwner::Overlay(meshes.clone()),
8446        )
8447    }
8448
8449    fn alloc_mesh_uniform(&mut self, u: MeshUniform) -> Option<u64> {
8450        let size = std::mem::size_of::<MeshUniform>() as u64;
8451        let next = self
8452            .mesh_uniform_head
8453            .checked_add(self.mesh_uniform_alignment)?;
8454        if next > self.mesh_uniform_cap || next > u64::from(u32::MAX) {
8455            log::warn!("mesh uniform buffer exhausted; remaining meshes skipped");
8456            return None;
8457        }
8458        let slot = self.mesh_uniform_head;
8459        if slot.checked_add(size)? > self.mesh_uniform_cap {
8460            return None;
8461        }
8462        self.queue
8463            .write_buffer(&self.mesh_uniform_buf, slot, bytemuck::bytes_of(&u));
8464        self.mesh_uniform_head = next;
8465        Some(slot)
8466    }
8467
8468    /// Write `affines` as one contiguous instance run. Returns the byte offset
8469    /// and record count for a `Cmd::VectorMesh`. Every placement in the run
8470    /// shares one paint uniform, which is the whole point: N copies of a rig
8471    /// frame cost one uniform slot and one draw.
8472    fn alloc_mesh_instances(&mut self, affines: &[[f32; 6]]) -> Option<(u64, u32)> {
8473        let count = u32::try_from(affines.len()).ok()?;
8474        if count == 0 {
8475            return None;
8476        }
8477        let stride = std::mem::size_of::<MeshInstance>() as u64;
8478        let bytes = u64::from(count) * stride;
8479        let slot = self.mesh_instance_head;
8480        let next = slot.checked_add(bytes)?;
8481        if next > self.mesh_instance_cap {
8482            log::warn!("mesh instance buffer exhausted; remaining meshes skipped");
8483            return None;
8484        }
8485        let mut staging = Vec::with_capacity(affines.len());
8486        for affine in affines {
8487            staging.push(MeshInstance::new(*affine));
8488        }
8489        self.queue.write_buffer(
8490            &self.mesh_instance_buf,
8491            slot,
8492            bytemuck::cast_slice(&staging),
8493        );
8494        self.mesh_instance_head = next;
8495        Some((slot, count))
8496    }
8497
8498    /// Shared isolation + backdrop composite for a source that needs the
8499    /// backdrop: snapshot the parent's region, render the source alone into a
8500    /// translator-owned graphics layer via `emit_source`, then composite that
8501    /// layer over the snapshot with the backdrop-blend shader. Works for
8502    /// surface parents and layer parents alike: the snapshot copy, isolation
8503    /// layer, and composite quad are all expressed in the parent target's
8504    /// pixel space. Callers must invoke this while the current pass targets
8505    /// the recorded parent: the translator never splits passes between here
8506    /// and the appended composite (only `BeginLayer`/perspective push new
8507    /// passes, and neither can intervene mid-call). The executor re-checks
8508    /// this (`BlendLayer.parent`) and skips a misplaced composite rather than
8509    /// drawing over the wrong target.
8510    ///
8511    /// `bounds` is the source's AABB in parent-target pixels; `emit_source`
8512    /// receives the isolation pass, the source-shifted transform, and the
8513    /// layer size in pixels.
8514    #[allow(clippy::too_many_arguments)]
8515    fn emit_isolated_blend_source(
8516        &mut self,
8517        bounds: repose_core::Rect,
8518        blend: repose_core::BlendMode,
8519        current_transform: &repose_core::Transform,
8520        current_pass: &mut Pass,
8521        active_clips: &[ActiveClip],
8522        passes: &mut Vec<Pass>,
8523        id_head: &mut u32,
8524        pass_id_head: &mut u64,
8525        ids_used: &mut Vec<u32>,
8526        scissor: (u32, u32, u32, u32),
8527        encoder: &mut wgpu::CommandEncoder,
8528        fb_w: f32,
8529        fb_h: f32,
8530        emit_source: impl FnOnce(
8531            &mut Self,
8532            &mut Pass,
8533            &repose_core::Transform,
8534            f32,
8535            f32,
8536            &mut wgpu::CommandEncoder,
8537        ),
8538    ) {
8539        if scissor.2 == 0 || scissor.3 == 0 || active_clips.iter().any(ActiveClip::blocked) {
8540            return;
8541        }
8542        let parent_target = current_pass.target;
8543        let (parent_w, parent_h) = match parent_target {
8544            PassTarget::Surface => (fb_w, fb_h),
8545            PassTarget::Layer(id) => match self.layer_pool.get(&id) {
8546                Some(layer) => (layer.width as f32, layer.height as f32),
8547                None => return,
8548            },
8549        };
8550        let local_rect = intersect(
8551            bounds,
8552            repose_core::Rect {
8553                x: 0.0,
8554                y: 0.0,
8555                w: parent_w,
8556                h: parent_h,
8557            },
8558        );
8559        if !local_rect.x.is_finite()
8560            || !local_rect.y.is_finite()
8561            || !local_rect.w.is_finite()
8562            || !local_rect.h.is_finite()
8563            || local_rect.w <= 0.0
8564            || local_rect.h <= 0.0
8565            || local_rect.w > self.device.limits().max_texture_dimension_2d as f32
8566            || local_rect.h > self.device.limits().max_texture_dimension_2d as f32
8567        {
8568            return;
8569        }
8570        let layer_rect = repose_core::Rect {
8571            x: local_rect.x.floor(),
8572            y: local_rect.y.floor(),
8573            w: (local_rect.x + local_rect.w).ceil() - local_rect.x.floor(),
8574            h: (local_rect.y + local_rect.h).ceil() - local_rect.y.floor(),
8575        };
8576        let w = layer_rect.w;
8577        let h = layer_rect.h;
8578        let local_rect = layer_rect;
8579        if ids_used.len() >= MAX_TRANSIENT_GRAPHICS_LAYERS {
8580            return;
8581        }
8582        let layer_id = *id_head;
8583        *id_head = id_head.wrapping_add(1);
8584        ids_used.push(layer_id);
8585
8586        // Snapshot the parent target region before compositing: the blend
8587        // shader samples it as the backdrop. The copy runs at execution
8588        // time, after all earlier passes have rendered.
8589        let snapshot_format = match parent_target {
8590            PassTarget::Surface if self.working_space => wgpu::TextureFormat::Rgba16Float,
8591            PassTarget::Surface => self.output_format,
8592            PassTarget::Layer(_) => self.layer_target_format(),
8593        };
8594        if !self.alloc_blend_snapshot(layer_id, w as u32, h as u32, snapshot_format) {
8595            log::warn!("blend snapshot budget exhausted; blend skipped");
8596            return;
8597        }
8598        let source_pass_id = *pass_id_head;
8599        *pass_id_head += 1;
8600        let resumed_pass_id = *pass_id_head;
8601        *pass_id_head += 1;
8602        self.blend_copies.push(BlendCopy {
8603            pass_id: resumed_pass_id,
8604            blend_id: layer_id,
8605            target: parent_target,
8606            region: layer_rect,
8607        });
8608
8609        // Render the mesh alone into the layer (layer-local shift so the
8610        // layer owns exactly the mesh bbox), then resume the parent pass.
8611        // The composite runs in the resumed parent pass.
8612        let mut layer_pass = Pass {
8613            id: source_pass_id,
8614            target: PassTarget::Layer(layer_id),
8615            initial_scissor: (0, 0, w as u32, h as u32),
8616            active_scissor: Some((0, 0, w as u32, h as u32)),
8617            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
8618            cmds: Vec::new(),
8619        };
8620        self.replay_active_clips(
8621            active_clips,
8622            current_transform,
8623            (local_rect.x, local_rect.y),
8624            (w, h),
8625            &mut layer_pass,
8626            encoder,
8627        );
8628        self.get_or_create_layer(layer_id, w as u32, h as u32, layer_rect, true);
8629        let shift = repose_core::Transform::translate(-local_rect.x, -local_rect.y);
8630        let local = current_transform.combine(&shift);
8631        emit_source(self, &mut layer_pass, &local, w, h, encoder);
8632        let saved = std::mem::replace(
8633            current_pass,
8634            Pass {
8635                id: resumed_pass_id,
8636                target: parent_target,
8637                initial_scissor: scissor,
8638                active_scissor: Some(scissor),
8639                clear_color: None,
8640                cmds: Vec::new(),
8641            },
8642        );
8643        passes.push(layer_pass);
8644        passes.push(saved);
8645
8646        // Composite quad over the mesh bbox in the parent target's pixel
8647        // space. The executor maps this NDC against `parent`, so layer
8648        // parents at any origin work.
8649        let ndc = {
8650            let cx = local_rect.x + local_rect.w * 0.5;
8651            let cy = local_rect.y + local_rect.h * 0.5;
8652            let ndc_cx = (cx / parent_w) * 2.0 - 1.0;
8653            let ndc_cy = 1.0 - (cy / parent_h) * 2.0;
8654            let ndc_w = (local_rect.w / parent_w) * 2.0;
8655            let ndc_h = (local_rect.h / parent_h) * 2.0;
8656            [ndc_cx, ndc_cy, ndc_w, ndc_h]
8657        };
8658        let inst = BlendInstance {
8659            xywh: ndc,
8660            uv: [0.0, 0.0, 1.0, 1.0],
8661            color: [1.0, 1.0, 1.0, 1.0],
8662            fwd_mat: [1.0, 0.0, 0.0, 1.0],
8663            mode: blend.shader_mode(),
8664            _pad: [0.0; 3],
8665        };
8666        if self
8667            .blend_ring
8668            .grow_to_fit(
8669                &self.device,
8670                encoder,
8671                std::mem::size_of::<BlendInstance>() as u64,
8672            )
8673            .is_err()
8674        {
8675            return;
8676        }
8677        let bytes = bytemuck::bytes_of(&inst);
8678        let Ok(off) = self.blend_ring.alloc_write(&self.queue, bytes) else {
8679            return;
8680        };
8681        current_pass.cmds.push(Cmd::BlendLayer {
8682            off,
8683            cnt: 1,
8684            src_layer: layer_id,
8685            dst_layer: Some(layer_id),
8686            parent: parent_target,
8687            scissor,
8688        });
8689    }
8690
8691    /// [`Self::emit_isolated_blend_source`] for a `VectorMesh`: the isolation
8692    /// layer owns exactly the mesh bbox, shifted so the mesh lands at its
8693    /// world position inside the layer.
8694    #[allow(clippy::too_many_arguments)]
8695    fn emit_isolated_blend(
8696        &mut self,
8697        mesh: std::sync::Arc<repose_core::VectorMeshData>,
8698        transform: [f32; 6],
8699        paint: repose_core::PaintDesc,
8700        blend: repose_core::BlendMode,
8701        current_transform: &repose_core::Transform,
8702        current_pass: &mut Pass,
8703        active_clips: &[ActiveClip],
8704        passes: &mut Vec<Pass>,
8705        id_head: &mut u32,
8706        pass_id_head: &mut u64,
8707        ids_used: &mut Vec<u32>,
8708        scissor: (u32, u32, u32, u32),
8709        encoder: &mut wgpu::CommandEncoder,
8710        fb_w: f32,
8711        fb_h: f32,
8712    ) {
8713        let affine = combine_mesh_affine(current_transform, transform);
8714        let bounds = mesh_aabb(&mesh, affine);
8715        self.emit_isolated_blend_source(
8716            bounds,
8717            blend,
8718            current_transform,
8719            current_pass,
8720            active_clips,
8721            passes,
8722            id_head,
8723            pass_id_head,
8724            ids_used,
8725            scissor,
8726            encoder,
8727            fb_w,
8728            fb_h,
8729            |slf, pass, local, _w, _h, _encoder| {
8730                slf.emit_vector_mesh(
8731                    local,
8732                    &mesh,
8733                    transform,
8734                    &paint,
8735                    repose_core::BlendMode::Alpha,
8736                    &mut pass.cmds,
8737                );
8738            },
8739        );
8740    }
8741
8742    /// [`Self::emit_isolated_blend_source`] for an `ImageBlended` quad. The
8743    /// isolation layer is sized to the fitted draw rect and the image is
8744    /// sampled with its own UVs, so `fit`/`filter`/`source_rect` survive the
8745    /// round trip; the source layer composites with `Alpha` and the blend
8746    /// itself happens in the backdrop pass.
8747    #[allow(clippy::too_many_arguments)]
8748    fn emit_isolated_image(
8749        &mut self,
8750        draw_rect: repose_core::Rect,
8751        handle: u64,
8752        uv_rect: [f32; 4],
8753        tint: repose_core::Color,
8754        tile: bool,
8755        filter: ImageFilter,
8756        blend: repose_core::BlendMode,
8757        current_transform: &repose_core::Transform,
8758        current_pass: &mut Pass,
8759        active_clips: &[ActiveClip],
8760        passes: &mut Vec<Pass>,
8761        id_head: &mut u32,
8762        pass_id_head: &mut u64,
8763        ids_used: &mut Vec<u32>,
8764        scissor: (u32, u32, u32, u32),
8765        encoder: &mut wgpu::CommandEncoder,
8766        fb_w: f32,
8767        fb_h: f32,
8768    ) {
8769        let bounds = affine_aabb(current_transform, &draw_rect);
8770        self.emit_isolated_blend_source(
8771            bounds,
8772            blend,
8773            current_transform,
8774            current_pass,
8775            active_clips,
8776            passes,
8777            id_head,
8778            pass_id_head,
8779            ids_used,
8780            scissor,
8781            encoder,
8782            fb_w,
8783            fb_h,
8784            |slf, pass, local, w, h, encoder| {
8785                let (ndc_center, fwd_mat) = rect_to_instance_ndc(draw_rect, local, w, h);
8786                let inst = GlyphInstance {
8787                    xywh: ndc_center,
8788                    uv: uv_rect,
8789                    color: tint.to_linear(),
8790                    fwd_mat,
8791                };
8792                if let Some((off, _)) =
8793                    slf.glyph_color
8794                        .upload(&slf.device, &slf.queue, encoder, &[inst])
8795                {
8796                    pass.cmds.push(Cmd::ImageRgba {
8797                        off,
8798                        cnt: 1,
8799                        handle,
8800                        filter,
8801                        tile,
8802                        blend: repose_core::BlendMode::Alpha,
8803                    });
8804                }
8805            },
8806        );
8807    }
8808
8809    fn recycle_blend_snapshot(&mut self, snapshot: BlendSnapshot) {
8810        let key = snapshot.key;
8811        if let Some(old) = self.blend_snapshot_pool.insert(key, snapshot) {
8812            self.blend_snapshot_bytes_total =
8813                self.blend_snapshot_bytes_total.saturating_sub(old.bytes);
8814        }
8815        while self.blend_snapshot_bytes_total > MAX_BLEND_SNAPSHOT_BYTES
8816            || self.blend_snapshot_pool.len() > MAX_BLEND_SNAPSHOT_POOL_ENTRIES
8817        {
8818            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8819                break;
8820            };
8821            if let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) {
8822                self.blend_snapshot_bytes_total = self
8823                    .blend_snapshot_bytes_total
8824                    .saturating_sub(snapshot.bytes);
8825            }
8826        }
8827    }
8828
8829    fn recycle_blend_snapshots(&mut self) {
8830        for (_, snapshot) in std::mem::take(&mut self.blend_snapshots) {
8831            self.recycle_blend_snapshot(snapshot);
8832        }
8833    }
8834
8835    fn alloc_blend_snapshot(
8836        &mut self,
8837        layer_id: u32,
8838        w: u32,
8839        h: u32,
8840        format: wgpu::TextureFormat,
8841    ) -> bool {
8842        let key = BlendSnapshotKey {
8843            format,
8844            width: w,
8845            height: h,
8846        };
8847        if self
8848            .blend_snapshots
8849            .get(&layer_id)
8850            .is_some_and(|snapshot| snapshot.key == key)
8851        {
8852            return true;
8853        }
8854        if w == 0 || h == 0 {
8855            return false;
8856        }
8857        if let Some(old) = self.blend_snapshots.remove(&layer_id) {
8858            self.recycle_blend_snapshot(old);
8859        }
8860        if let Some(snapshot) = self.blend_snapshot_pool.remove(&key) {
8861            self.blend_snapshots.insert(layer_id, snapshot);
8862            return true;
8863        }
8864        let Ok(bytes) = texture_storage_bytes(format, w, h) else {
8865            return false;
8866        };
8867        if bytes > MAX_BLEND_SNAPSHOT_BYTES {
8868            return false;
8869        }
8870        while self.blend_snapshot_bytes_total.saturating_add(bytes) > MAX_BLEND_SNAPSHOT_BYTES
8871            || self.gpu_bytes_total().saturating_add(bytes) > self.gpu_budget_bytes
8872        {
8873            let Some(victim) = self.blend_snapshot_pool.keys().next().copied() else {
8874                return false;
8875            };
8876            let Some(snapshot) = self.blend_snapshot_pool.remove(&victim) else {
8877                return false;
8878            };
8879            self.blend_snapshot_bytes_total = self
8880                .blend_snapshot_bytes_total
8881                .saturating_sub(snapshot.bytes);
8882        }
8883        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
8884            label: Some("blend backdrop snapshot"),
8885            size: wgpu::Extent3d {
8886                width: w,
8887                height: h,
8888                depth_or_array_layers: 1,
8889            },
8890            mip_level_count: 1,
8891            sample_count: 1,
8892            dimension: wgpu::TextureDimension::D2,
8893            format,
8894            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
8895            view_formats: &[],
8896        });
8897        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
8898        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8899            label: Some("blend snapshot bind"),
8900            layout: &self.image_bind_layout_rgba,
8901            entries: &[
8902                wgpu::BindGroupEntry {
8903                    binding: 0,
8904                    resource: wgpu::BindingResource::TextureView(&view),
8905                },
8906                wgpu::BindGroupEntry {
8907                    binding: 1,
8908                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
8909                },
8910            ],
8911        });
8912        self.blend_snapshot_bytes_total = self.blend_snapshot_bytes_total.saturating_add(bytes);
8913        self.blend_snapshots.insert(
8914            layer_id,
8915            BlendSnapshot {
8916                key,
8917                texture: tex,
8918                bind,
8919                bytes,
8920            },
8921        );
8922        true
8923    }
8924
8925    fn remove_blend_snapshot(&mut self, layer_id: u32) {
8926        if let Some(snapshot) = self.blend_snapshots.remove(&layer_id) {
8927            self.recycle_blend_snapshot(snapshot);
8928        }
8929    }
8930
8931    fn blend_snapshot_texture(&self, layer_id: u32) -> Option<wgpu::Texture> {
8932        self.blend_snapshots
8933            .get(&layer_id)
8934            .map(|s| s.texture.clone())
8935    }
8936
8937    #[allow(clippy::too_many_arguments)]
8938    fn emit_vector_mesh(
8939        &mut self,
8940        current_transform: &Transform,
8941        mesh: &Arc<repose_core::VectorMeshData>,
8942        transform: [f32; 6],
8943        paint: &repose_core::PaintDesc,
8944        blend: repose_core::BlendMode,
8945        cmds: &mut Vec<Cmd>,
8946    ) {
8947        self.emit_vector_mesh_affines(current_transform, mesh, &[transform], paint, blend, cmds);
8948    }
8949
8950    /// One mesh, one paint, N placements, one draw. `transforms` are local
8951    /// affines, each combined with `current_transform` before upload, so a run
8952    /// of placements sharing a parent transform and a paint collapses into a
8953    /// single indexed draw with `icnt` instances.
8954    #[allow(clippy::too_many_arguments)]
8955    fn emit_vector_mesh_affines(
8956        &mut self,
8957        current_transform: &Transform,
8958        mesh: &Arc<repose_core::VectorMeshData>,
8959        transforms: &[[f32; 6]],
8960        paint: &repose_core::PaintDesc,
8961        blend: repose_core::BlendMode,
8962        cmds: &mut Vec<Cmd>,
8963    ) {
8964        let Some(mesh_gpu) = self.upload_mesh_geometry(mesh) else {
8965            return;
8966        };
8967        let Some(uoff) = self.alloc_mesh_uniform(mesh_uniform_from_paint(paint)) else {
8968            return;
8969        };
8970        let affines: Vec<[f32; 6]> = transforms
8971            .iter()
8972            .map(|transform| combine_mesh_affine(current_transform, *transform))
8973            .collect();
8974        let Some((ioff, icnt)) = self.alloc_mesh_instances(&affines) else {
8975            return;
8976        };
8977        cmds.push(Cmd::VectorMesh {
8978            mesh: mesh_gpu,
8979            uoff,
8980            ioff,
8981            icnt,
8982            blend,
8983        });
8984    }
8985
8986    fn touch_callback_scope(&mut self, scope: CallbackScopeKey) {
8987        self.callback_scope_clock = self.callback_scope_clock.wrapping_add(1);
8988        let tick = self.callback_scope_clock;
8989        self.callback_scope_uses.insert(
8990            scope,
8991            CallbackScopeUse {
8992                tick,
8993                frame: self.frame_index,
8994            },
8995        );
8996    }
8997
8998    fn remove_callback_scope(&mut self, scope: &CallbackScopeKey) {
8999        self.callback_scoped_resources.remove(scope);
9000        self.callback_scope_uses.remove(scope);
9001        self.callback_scope_payloads.remove(scope);
9002    }
9003
9004    fn evict_callback_scope(&mut self, protected: &HashSet<CallbackScopeKey>) -> bool {
9005        let candidate = self
9006            .callback_scoped_resources
9007            .keys()
9008            .filter(|scope| !protected.contains(*scope))
9009            .min_by_key(|scope| {
9010                self.callback_scope_uses
9011                    .get(scope)
9012                    .map(|usage| usage.tick)
9013                    .unwrap_or(0)
9014            })
9015            .cloned();
9016        let Some(scope) = candidate else {
9017            return false;
9018        };
9019        self.remove_callback_scope(&scope);
9020        true
9021    }
9022
9023    fn prune_callback_scopes(&mut self) {
9024        let current_frame = self.frame_index;
9025        let mut dead: Vec<(CallbackScopeKey, u64)> = self
9026            .callback_scope_uses
9027            .iter()
9028            .filter_map(|(scope, usage)| {
9029                let payload_dead = self
9030                    .callback_scope_payloads
9031                    .get(scope)
9032                    .is_none_or(|weak| weak.upgrade().is_none());
9033                (payload_dead && !scope.callback.reusable).then(|| (scope.clone(), usage.tick))
9034            })
9035            .collect();
9036        dead.sort_by_key(|(_, tick)| *tick);
9037        for (scope, _) in dead {
9038            self.remove_callback_scope(&scope);
9039        }
9040
9041        if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
9042            return;
9043        }
9044        let mut inactive: Vec<(CallbackScopeKey, u64)> = self
9045            .callback_scope_uses
9046            .iter()
9047            .filter(|(_, usage)| usage.frame != current_frame)
9048            .map(|(scope, usage)| (scope.clone(), usage.tick))
9049            .collect();
9050        inactive.sort_by_key(|(_, tick)| *tick);
9051        for (scope, _) in inactive {
9052            if self.callback_scoped_resources.len() <= MAX_CALLBACK_SCOPES {
9053                break;
9054            }
9055            self.remove_callback_scope(&scope);
9056        }
9057    }
9058
9059    pub fn begin_frame(&mut self) -> anyhow::Result<()> {
9060        if self.frame_active {
9061            self.last_render_error = Some("renderer frame is already active".into());
9062            anyhow::bail!("renderer frame is already active");
9063        }
9064        self.last_render_error = None;
9065        self.frame_active = true;
9066        self.frame_index = self.frame_index.wrapping_add(1);
9067        self.apply_atlas_pending();
9068        self.slug_cache.next_frame();
9069        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
9070            composite.begin_frame();
9071        }
9072        for resources in self.callback_scoped_resources.values_mut() {
9073            if let Some(composite) = resources.get_mut::<DepthComposite>() {
9074                composite.begin_frame();
9075            }
9076        }
9077        Ok(())
9078    }
9079
9080    pub fn end_frame(&mut self) {
9081        if !self.frame_active {
9082            return;
9083        }
9084        if let Some(composite) = self.callback_resources.get_mut::<DepthComposite>() {
9085            composite.end_frame();
9086        }
9087        for resources in self.callback_scoped_resources.values_mut() {
9088            if let Some(composite) = resources.get_mut::<DepthComposite>() {
9089                composite.end_frame();
9090            }
9091        }
9092        self.prune_callback_scopes();
9093        self.evict_unused_images();
9094        self.frame_active = false;
9095    }
9096
9097    pub fn last_render_error(&self) -> Option<&str> {
9098        self.last_render_error.as_deref()
9099    }
9100
9101    pub fn render_scene_to_encoder(
9102        &mut self,
9103        scene: &Scene,
9104        encoder: &mut wgpu::CommandEncoder,
9105        target_view: &wgpu::TextureView,
9106        clear_color_override: Option<[f64; 4]>,
9107    ) {
9108        self.render_scene_to_encoder_with_texture(
9109            scene,
9110            encoder,
9111            target_view,
9112            None,
9113            clear_color_override,
9114        )
9115    }
9116
9117    /// Same as [`render_scene_to_encoder`](Self::render_scene_to_encoder),
9118    /// plus the render target texture for isolated-blend backdrop snapshots.
9119    /// Pass `None` when the texture is unavailable (swapchain views); then
9120    /// surface-targeted isolated blends are skipped (the isolated source
9121    /// layer composites nowhere, so the mesh disappears).
9122    pub fn render_scene_to_encoder_with_texture(
9123        &mut self,
9124        scene: &Scene,
9125        encoder: &mut wgpu::CommandEncoder,
9126        target_view: &wgpu::TextureView,
9127        target_texture: Option<&wgpu::Texture>,
9128        clear_color_override: Option<[f64; 4]>,
9129    ) {
9130        if self.output_width == 0 || self.output_height == 0 {
9131            return;
9132        }
9133        self.sync_text_caches();
9134        fn visible(aabb: repose_core::Rect, clip: repose_core::Rect) -> bool {
9135            if !aabb.x.is_finite()
9136                || !aabb.y.is_finite()
9137                || !aabb.w.is_finite()
9138                || !aabb.h.is_finite()
9139                || aabb.w <= 0.0
9140                || aabb.h <= 0.0
9141            {
9142                return false;
9143            }
9144            let aabb = repose_core::Rect {
9145                x: aabb.x - 1.0,
9146                y: aabb.y - 1.0,
9147                w: aabb.w + 2.0,
9148                h: aabb.h + 2.0,
9149            };
9150            let clipped = intersect(aabb, clip);
9151            clipped.x.is_finite()
9152                && clipped.y.is_finite()
9153                && clipped.w.is_finite()
9154                && clipped.h.is_finite()
9155                && clipped.w > 0.0
9156                && clipped.h > 0.0
9157        }
9158
9159        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
9160            let x0 = (x / fb_w) * 2.0 - 1.0;
9161            let y0 = 1.0 - (y / fb_h) * 2.0;
9162            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
9163            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
9164            let min_x = x0.min(x1);
9165            let min_y = y0.min(y1);
9166            let w_ndc = (x1 - x0).abs();
9167            let h_ndc = (y1 - y0).abs();
9168            [min_x, min_y, w_ndc, h_ndc]
9169        }
9170
9171        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
9172            if fb_w == 0
9173                || fb_h == 0
9174                || !r.x.is_finite()
9175                || !r.y.is_finite()
9176                || !r.w.is_finite()
9177                || !r.h.is_finite()
9178                || r.w <= 0.0
9179                || r.h <= 0.0
9180            {
9181                return (0, 0, 0, 0);
9182            }
9183            // Inward: a clip must not admit any pixel outside the rect it
9184            // came from. Rounding out let clipped content bleed a pixel past
9185            // every clip edge, which shows up as a 1px line hugging the edge
9186            // of a letterboxed or clipped view.
9187            let x0 = r.x.ceil().max(0.0).min(fb_w as f32);
9188            let y0 = r.y.ceil().max(0.0).min(fb_h as f32);
9189            let x1 = (r.x + r.w).floor().max(x0).min(fb_w as f32);
9190            let y1 = (r.y + r.h).floor().max(y0).min(fb_h as f32);
9191            if x1 <= x0 || y1 <= y0 {
9192                return (0, 0, 0, 0);
9193            }
9194            (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
9195        }
9196
9197        let fb_w = self.output_width as f32;
9198        let fb_h = self.output_height as f32;
9199
9200        let mut passes: Vec<Pass> = Vec::with_capacity(1);
9201        let clear_color = clear_color_override
9202            .map(|color| {
9203                let alpha = color[3];
9204                [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha]
9205            })
9206            .unwrap_or_else(|| {
9207                let color = scene.clear_color.to_linear();
9208                let alpha = color[3] as f64;
9209                [
9210                    color[0] as f64 * alpha,
9211                    color[1] as f64 * alpha,
9212                    color[2] as f64 * alpha,
9213                    alpha,
9214                ]
9215            });
9216        let mut next_pass_id = 0u64;
9217        let mut current_pass: Pass = Pass {
9218            id: next_pass_id,
9219            target: PassTarget::Surface,
9220            initial_scissor: (0, 0, self.output_width, self.output_height),
9221            active_scissor: Some((0, 0, self.output_width, self.output_height)),
9222            clear_color: Some([
9223                clear_color[0] as f32,
9224                clear_color[1] as f32,
9225                clear_color[2] as f32,
9226                clear_color[3] as f32,
9227            ]),
9228            cmds: Vec::with_capacity(scene.nodes.len()),
9229        };
9230        next_pass_id += 1;
9231        let mut target_stack: Vec<PassTarget> = Vec::new();
9232        let mut layer_stack: Vec<LayerState> = Vec::new();
9233        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
9234
9235        struct Batch {
9236            rects: Vec<RectInstance>,
9237            borders: Vec<BorderInstance>,
9238            ellipses: Vec<EllipseInstance>,
9239            e_borders: Vec<EllipseBorderInstance>,
9240            arcs: Vec<ArcInstance>,
9241            masks: Vec<GlyphInstance>,
9242            colors: Vec<GlyphInstance>,
9243        }
9244
9245        impl Batch {
9246            fn new() -> Self {
9247                Self {
9248                    rects: vec![],
9249                    borders: vec![],
9250                    ellipses: vec![],
9251                    e_borders: vec![],
9252                    arcs: vec![],
9253                    masks: vec![],
9254                    colors: vec![],
9255                }
9256            }
9257
9258            fn is_empty(&self) -> bool {
9259                self.rects.is_empty()
9260                    && self.borders.is_empty()
9261                    && self.ellipses.is_empty()
9262                    && self.e_borders.is_empty()
9263                    && self.arcs.is_empty()
9264                    && self.masks.is_empty()
9265                    && self.colors.is_empty()
9266            }
9267
9268            fn flush(
9269                &mut self,
9270                pipes: (
9271                    &mut InstancedPipe<RectInstance>,
9272                    &mut InstancedPipe<BorderInstance>,
9273                    &mut InstancedPipe<EllipseInstance>,
9274                    &mut InstancedPipe<EllipseBorderInstance>,
9275                    &mut InstancedPipe<ArcInstance>,
9276                ),
9277                glyph_pipes: (
9278                    &mut InstancedPipe<GlyphInstance>,
9279                    &mut InstancedPipe<GlyphInstance>,
9280                ),
9281                device: &wgpu::Device,
9282                queue: &wgpu::Queue,
9283                encoder: &mut wgpu::CommandEncoder,
9284                cmds: &mut Vec<Cmd>,
9285            ) {
9286                let (rects, borders, ellipses, e_borders, arcs) = pipes;
9287                let (masks, colors) = glyph_pipes;
9288
9289                macro_rules! flush_one {
9290                    ($buf:ident, $pipe:expr, $variant:ident) => {
9291                        if !self.$buf.is_empty() {
9292                            if let Some((off, cnt)) =
9293                                $pipe.upload(device, queue, encoder, &self.$buf)
9294                            {
9295                                cmds.push(Cmd::$variant { off, cnt });
9296                            }
9297                            self.$buf.clear();
9298                        }
9299                    };
9300                }
9301
9302                flush_one!(rects, rects, Rect);
9303                flush_one!(borders, borders, Border);
9304                flush_one!(ellipses, ellipses, Ellipse);
9305                flush_one!(e_borders, e_borders, EllipseBorder);
9306                flush_one!(arcs, arcs, Arc);
9307                flush_one!(masks, masks, GlyphsMask);
9308                flush_one!(colors, colors, GlyphsColor);
9309            }
9310        }
9311
9312        self.rects.reset();
9313        self.borders.reset();
9314        self.ellipses.reset();
9315        self.ellipse_borders.reset();
9316        self.arcs.reset();
9317        self.glyph_mask.reset();
9318        self.glyph_color.reset();
9319        self.clip_ring.reset();
9320        self.blur_ring.reset();
9321        self.nv12.reset();
9322
9323        self.slug_ring.reset();
9324        self.mesh_uniform_head = 0;
9325        self.mesh_instance_head = 0;
9326        self.projective_ring.reset();
9327        self.blend_ring.reset();
9328        for id in self.flatten_layer_ids.drain(..) {
9329            if let Some(layer) = self.layer_pool.remove(&id) {
9330                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
9331                if layer.transient {
9332                    self.transient_layer_bytes_total =
9333                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
9334                }
9335            }
9336        }
9337        self.producer_layer_ids.clear();
9338        self.recycle_blend_snapshots();
9339        self.blend_copies.clear();
9340        let mut batch = Batch::new();
9341        // The blend mode is part of the run key: instances only batch together when
9342        // they resolve to the same pipeline.
9343        let mut image_run: Option<(
9344            u64,
9345            ImageFilter,
9346            bool,
9347            repose_core::BlendMode,
9348            Vec<GlyphInstance>,
9349        )> = None;
9350        let mut nv12_run: Option<(u64, ImageFilter, repose_core::BlendMode, Vec<Nv12Instance>)> =
9351            None;
9352        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
9353        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
9354        let mut flatten_stack: Vec<FlattenRecord> = Vec::new();
9355        let mut flatten_id_head: u32 = FLATTEN_ID_BASE;
9356        let mut flatten_ids_used: Vec<u32> = Vec::new();
9357        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
9358        let mut active_clips: Vec<ActiveClip> = Vec::with_capacity(8);
9359        let mut root_clip_rect = repose_core::Rect {
9360            x: 0.0,
9361            y: 0.0,
9362            w: fb_w,
9363            h: fb_h,
9364        };
9365
9366        let mut current_prim: Option<&'static str> = None;
9367
9368        macro_rules! flush_if_prim_changed {
9369            ($prim:literal, $pipe:expr) => {
9370                if current_prim != Some($prim) {
9371                    flush_batch!();
9372                    current_prim = Some($prim);
9373                }
9374            };
9375        }
9376
9377        macro_rules! flush_image_runs {
9378            () => {{
9379                if let Some((handle, filter, tile, blend, instances)) = image_run.take()
9380                    && let Some((off, cnt)) =
9381                        self.glyph_color
9382                            .upload(&self.device, &self.queue, encoder, &instances)
9383                {
9384                    current_pass.cmds.push(Cmd::ImageRgba {
9385                        off,
9386                        cnt,
9387                        handle,
9388                        filter,
9389                        tile,
9390                        blend,
9391                    });
9392                }
9393                if let Some((handle, filter, blend, instances)) = nv12_run.take()
9394                    && let Some((off, cnt)) =
9395                        self.nv12
9396                            .upload(&self.device, &self.queue, encoder, &instances)
9397                {
9398                    current_pass.cmds.push(Cmd::ImageNv12 {
9399                        off,
9400                        cnt,
9401                        handle,
9402                        filter,
9403                        blend,
9404                    });
9405                }
9406            }};
9407        }
9408
9409        macro_rules! flush_primitives {
9410            () => {{
9411                if !batch.is_empty() {
9412                    batch.flush(
9413                        (
9414                            &mut self.rects,
9415                            &mut self.borders,
9416                            &mut self.ellipses,
9417                            &mut self.ellipse_borders,
9418                            &mut self.arcs,
9419                        ),
9420                        (&mut self.glyph_mask, &mut self.glyph_color),
9421                        &self.device,
9422                        &self.queue,
9423                        encoder,
9424                        &mut current_pass.cmds,
9425                    )
9426                }
9427            }};
9428        }
9429
9430        macro_rules! flush_batch {
9431            () => {{
9432                flush_image_runs!();
9433                flush_primitives!();
9434            }};
9435        }
9436        let mut pending_layer_composite: Option<Cmd> = None;
9437        let shadow_layer_ids: HashSet<u32> = scene
9438            .nodes
9439            .iter()
9440            .filter_map(|node| match node {
9441                SceneNode::CompositeShadow { layer_id, .. } => Some(*layer_id),
9442                _ => None,
9443            })
9444            .collect();
9445        macro_rules! flush_pending_layer {
9446            () => {
9447                if let Some(command) = pending_layer_composite.take() {
9448                    current_pass.cmds.push(command);
9449                }
9450            };
9451        }
9452        for node in &scene.nodes {
9453            let t_identity = Transform::identity();
9454            let current_transform = transform_stack.last().unwrap_or(&t_identity);
9455
9456            // `Image` and `ImageBlended` differ only in the blend mode, and
9457            // or-patterns cannot bind a field one variant lacks, so lift both
9458            // out of the match and share the whole image path below.
9459            let image_spec = match node {
9460                SceneNode::Image {
9461                    rect,
9462                    handle,
9463                    tint,
9464                    style,
9465                } => Some((*rect, *handle, *tint, *style, repose_core::BlendMode::Alpha)),
9466                SceneNode::ImageBlended {
9467                    rect,
9468                    handle,
9469                    tint,
9470                    style,
9471                    blend,
9472                } => Some((*rect, *handle, *tint, *style, *blend)),
9473                _ => None,
9474            };
9475            if let Some((rect, handle, tint, style, blend)) = image_spec {
9476                let ImagePaintStyle {
9477                    fit,
9478                    filter,
9479                    source_rect,
9480                    alignment,
9481                } = style;
9482                let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9483                if !visible(affine_aabb(current_transform, &rect), clip) {
9484                    continue;
9485                }
9486                flush_batch!();
9487                let (img_w, img_h, is_nv12) = match self.resolve_image_for_draw(handle) {
9488                    Some(wh) => wh,
9489                    None => {
9490                        log::warn!("Image handle {} not found", handle);
9491                        continue;
9492                    }
9493                };
9494                let Some(source) = resolve_image_source(source_rect, img_w, img_h) else {
9495                    log::warn!(
9496                        "Image handle {} has invalid source rect {:?}",
9497                        handle,
9498                        source_rect
9499                    );
9500                    continue;
9501                };
9502                let Some((draw_rect, uv_rect)) = image_fit_geometry(rect, fit, alignment, &source)
9503                else {
9504                    continue;
9505                };
9506                let tile = matches!(fit, ImageFit::Tile);
9507                // Mirror the mesh path: backdrop-dependent modes isolate,
9508                // and so do the fixed-function ones when the source is
9509                // translucent (their blend states assume opaque source).
9510                let translucent_fixed_blend = matches!(
9511                    blend,
9512                    repose_core::BlendMode::Add
9513                        | repose_core::BlendMode::Multiply
9514                        | repose_core::BlendMode::Screen
9515                        | repose_core::BlendMode::Darken
9516                        | repose_core::BlendMode::Lighten
9517                ) && tint.3 < 255;
9518                if blend.needs_isolation() || translucent_fixed_blend {
9519                    if is_nv12 {
9520                        // NV12 sampling is only wired into the image
9521                        // pipeline; isolating would need an RGBA layer.
9522                        log::warn!("blend on an NV12 image is not supported");
9523                    } else {
9524                        let blend_scissor = to_scissor(
9525                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
9526                            current_target_size.0 as u32,
9527                            current_target_size.1 as u32,
9528                        );
9529                        self.emit_isolated_image(
9530                            draw_rect,
9531                            handle,
9532                            uv_rect,
9533                            tint,
9534                            tile,
9535                            filter,
9536                            blend,
9537                            current_transform,
9538                            &mut current_pass,
9539                            &active_clips,
9540                            &mut passes,
9541                            &mut flatten_id_head,
9542                            &mut next_pass_id,
9543                            &mut flatten_ids_used,
9544                            blend_scissor,
9545                            encoder,
9546                            fb_w,
9547                            fb_h,
9548                        );
9549                    }
9550                    continue;
9551                }
9552                let (ndc_center, fwd_mat) = rect_to_instance_ndc(
9553                    draw_rect,
9554                    current_transform,
9555                    current_target_size.0,
9556                    current_target_size.1,
9557                );
9558                if is_nv12 {
9559                    if image_run.is_some() {
9560                        flush_image_runs!();
9561                    }
9562                    let (uv_x_offset, uv_y_offset) =
9563                        if let Some(ImageTex::Nv12 {
9564                            w, h, color_info, ..
9565                        }) = self.images.get(&handle)
9566                        {
9567                            let chroma_w = w.div_ceil(2).max(1) as f32;
9568                            let chroma_h = h.div_ceil(2).max(1) as f32;
9569                            match color_info.chroma_siting {
9570                                ChromaSiting::Center => (0.0, 0.0),
9571                                ChromaSiting::Left => (-0.5 / chroma_w, 0.0),
9572                                ChromaSiting::TopLeft => (-0.5 / chroma_w, -0.5 / chroma_h),
9573                            }
9574                        } else {
9575                            (0.0, 0.0)
9576                        };
9577                    let inst = Nv12Instance {
9578                        xywh: ndc_center,
9579                        uv: uv_rect,
9580                        color: tint.to_linear(),
9581                        uv_x_offset,
9582                        uv_y_offset,
9583                        fwd_mat,
9584                    };
9585                    if nv12_run
9586                        .as_ref()
9587                        .is_some_and(|(run_handle, run_filter, run_blend, _)| {
9588                            *run_handle != handle || *run_filter != filter || *run_blend != blend
9589                        })
9590                    {
9591                        flush_image_runs!();
9592                    }
9593                    match &mut nv12_run {
9594                        Some((run_handle, run_filter, run_blend, instances))
9595                            if *run_handle == handle
9596                                && *run_filter == filter
9597                                && *run_blend == blend =>
9598                        {
9599                            instances.push(inst)
9600                        }
9601                        _ => nv12_run = Some((handle, filter, blend, vec![inst])),
9602                    }
9603                } else {
9604                    if nv12_run.is_some() {
9605                        flush_image_runs!();
9606                    }
9607                    let inst = GlyphInstance {
9608                        xywh: ndc_center,
9609                        uv: uv_rect,
9610                        color: tint.to_linear(),
9611                        fwd_mat,
9612                    };
9613                    if image_run.as_ref().is_some_and(
9614                        |(run_handle, run_filter, run_tile, run_blend, _)| {
9615                            *run_handle != handle
9616                                || *run_filter != filter
9617                                || *run_tile != tile
9618                                || *run_blend != blend
9619                        },
9620                    ) {
9621                        flush_image_runs!();
9622                    }
9623                    match &mut image_run {
9624                        Some((run_handle, run_filter, run_tile, run_blend, instances))
9625                            if *run_handle == handle
9626                                && *run_filter == filter
9627                                && *run_tile == tile
9628                                && *run_blend == blend =>
9629                        {
9630                            instances.push(inst)
9631                        }
9632                        _ => image_run = Some((handle, filter, tile, blend, vec![inst])),
9633                    }
9634                }
9635                continue;
9636            }
9637
9638            match node {
9639                SceneNode::Rect {
9640                    rect,
9641                    brush,
9642                    radius,
9643                } => {
9644                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9645                    if !visible(affine_aabb(current_transform, rect), clip) {
9646                        continue;
9647                    }
9648                    flush_if_prim_changed!("rect", &self.rects);
9649                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9650                        *rect,
9651                        current_transform,
9652                        current_target_size.0,
9653                        current_target_size.1,
9654                    );
9655                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9656                        brush_to_shape_fields(brush, rect, current_transform);
9657                    batch.rects.push(RectInstance {
9658                        xywh: ndc,
9659                        radii: radius.map(|r| r.0),
9660                        color0,
9661                        color1,
9662                        fwd_mat,
9663                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9664                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9665                        _pad: [0.0; 3],
9666                    });
9667                }
9668                SceneNode::Border {
9669                    rect,
9670                    brush,
9671                    width,
9672                    radius,
9673                } => {
9674                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9675                    if !visible(affine_aabb(current_transform, rect), clip) {
9676                        continue;
9677                    }
9678                    flush_if_prim_changed!("border", &self.borders);
9679                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9680                        *rect,
9681                        current_transform,
9682                        current_target_size.0,
9683                        current_target_size.1,
9684                    );
9685                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9686                        brush_to_shape_fields(brush, rect, current_transform);
9687                    batch.borders.push(BorderInstance {
9688                        xywh: ndc,
9689                        radii: radius.map(|r| r.0),
9690                        color0,
9691                        color1,
9692                        fwd_mat,
9693                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9694                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9695                        stroke: width.0,
9696                        _pad: [0.0; 2],
9697                    });
9698                }
9699                SceneNode::Ellipse { rect, brush } => {
9700                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9701                    if !visible(affine_aabb(current_transform, rect), clip) {
9702                        continue;
9703                    }
9704                    flush_if_prim_changed!("ellipse", &self.ellipses);
9705                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9706                        *rect,
9707                        current_transform,
9708                        current_target_size.0,
9709                        current_target_size.1,
9710                    );
9711                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9712                        brush_to_shape_fields(brush, rect, current_transform);
9713                    batch.ellipses.push(EllipseInstance {
9714                        xywh: ndc,
9715                        color0,
9716                        color1,
9717                        fwd_mat,
9718                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9719                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9720                        _pad: [0.0; 1],
9721                    });
9722                }
9723                SceneNode::EllipseBorder { rect, brush, width } => {
9724                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9725                    let cull_pad = width.0.abs() * 0.5 + 2.0;
9726                    let cull_rect = repose_core::Rect {
9727                        x: rect.x - cull_pad,
9728                        y: rect.y - cull_pad,
9729                        w: rect.w + cull_pad * 2.0,
9730                        h: rect.h + cull_pad * 2.0,
9731                    };
9732                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9733                        continue;
9734                    }
9735                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
9736                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9737                        *rect,
9738                        current_transform,
9739                        current_target_size.0,
9740                        current_target_size.1,
9741                    );
9742                    let pad_px = width.0 * 0.5 + 2.0;
9743                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9744                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9745                        brush_to_shape_fields(brush, rect, current_transform);
9746                    batch.e_borders.push(EllipseBorderInstance {
9747                        xywh: ndc,
9748                        color0,
9749                        color1,
9750                        fwd_mat,
9751                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9752                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, 0),
9753                        stroke: width.0,
9754                        pad,
9755                        _pad: 0.0,
9756                    });
9757                }
9758                SceneNode::Arc {
9759                    rect,
9760                    start_angle,
9761                    sweep_angle,
9762                    stroke_width,
9763                    brush,
9764                    cap,
9765                } => {
9766                    if !start_angle.is_finite()
9767                        || !sweep_angle.is_finite()
9768                        || !stroke_width.0.is_finite()
9769                        || !rect.x.is_finite()
9770                        || !rect.y.is_finite()
9771                        || !rect.w.is_finite()
9772                        || !rect.h.is_finite()
9773                        || rect.w <= 0.0
9774                        || rect.h <= 0.0
9775                        || stroke_width.0 <= 0.0
9776                        || sweep_angle.abs() <= 1e-6
9777                    {
9778                        continue;
9779                    }
9780                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9781                    let cull_pad = stroke_width.0.abs() * 0.5 + 2.0;
9782                    let cull_rect = repose_core::Rect {
9783                        x: rect.x - cull_pad,
9784                        y: rect.y - cull_pad,
9785                        w: rect.w + cull_pad * 2.0,
9786                        h: rect.h + cull_pad * 2.0,
9787                    };
9788                    if !visible(affine_aabb(current_transform, &cull_rect), clip) {
9789                        continue;
9790                    }
9791                    flush_if_prim_changed!("arc", &self.arcs);
9792                    let (ndc, fwd_mat) = rect_to_instance_ndc(
9793                        *rect,
9794                        current_transform,
9795                        current_target_size.0.max(1.0),
9796                        current_target_size.1.max(1.0),
9797                    );
9798                    let pad_px = stroke_width.0 * 0.5 + 2.0;
9799                    let pad = (pad_px / current_target_size.0.max(1.0)) * 2.0;
9800                    let cap_val = match cap {
9801                        StrokeCap::Butt => 0.0,
9802                        StrokeCap::Round => 1.0,
9803                        StrokeCap::Square => 2.0,
9804                    };
9805                    let (brush_type, grad_kind, color0, color1, grad_p0, grad_p1, tile_mode) =
9806                        brush_to_shape_fields(brush, rect, current_transform);
9807                    let (start, sweep) = if *sweep_angle < 0.0 {
9808                        (*start_angle + *sweep_angle, -*sweep_angle)
9809                    } else {
9810                        (*start_angle, *sweep_angle)
9811                    };
9812                    batch.arcs.push(ArcInstance {
9813                        xywh: ndc,
9814                        color0,
9815                        color1,
9816                        fwd_mat,
9817                        grad: [grad_p0[0], grad_p0[1], grad_p1[0], grad_p1[1]],
9818                        bits: pack_shape_bits(brush_type, grad_kind, tile_mode, cap_val as u32),
9819                        start_angle: start,
9820                        sweep_angle: sweep,
9821                        stroke: stroke_width.0,
9822                        pad,
9823                        _pad: 0.0,
9824                    });
9825                }
9826                SceneNode::Text {
9827                    rect,
9828                    text,
9829                    color,
9830                    size,
9831                    style,
9832                } => {
9833                    let TextPaintStyle {
9834                        font_family,
9835                        font_weight,
9836                        font_style,
9837                        text_decoration,
9838                        letter_spacing,
9839                        font_variation_settings,
9840                        baseline_shift,
9841                        draw_style,
9842                        ..
9843                    } = style;
9844                    let px = size.0;
9845                    if !px.is_finite()
9846                        || px <= 0.0
9847                        || !rect.x.is_finite()
9848                        || !rect.y.is_finite()
9849                        || !rect.w.is_finite()
9850                        || !rect.h.is_finite()
9851                    {
9852                        continue;
9853                    }
9854                    let margin = px * 4.0 + letter_spacing.0.abs() * 2.0;
9855                    let text_bounds = repose_core::Rect {
9856                        x: rect.x - margin,
9857                        y: rect.y - margin,
9858                        w: rect.w + margin * 2.0,
9859                        h: rect.h + margin * 2.0,
9860                    };
9861                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
9862                    if !visible(affine_aabb(current_transform, &text_bounds), clip) {
9863                        continue;
9864                    }
9865                    flush_batch!(); // flush any prior primitives
9866
9867                    let lh_ratio = rect.h / px;
9868                    let fw = font_weight.0;
9869                    let fs = if *font_style == FontStyle::Italic {
9870                        1
9871                    } else {
9872                        0
9873                    };
9874                    let shaped = self.shape_text_cached(
9875                        text,
9876                        px,
9877                        lh_ratio,
9878                        *font_family,
9879                        fw,
9880                        fs,
9881                        letter_spacing.0,
9882                        font_variation_settings.as_ref(),
9883                    );
9884                    let baseline_y = shaped.glyphs.first().map(|g| rect.y + g.y);
9885
9886                    let fwd = forward_rs_mat(current_transform);
9887                    let has_linear = fwd != [1.0, 0.0, 0.0, 1.0];
9888
9889                    let lin = current_transform.linear();
9890                    let tr_x = current_transform.translate_x;
9891                    let tr_y = current_transform.translate_y;
9892
9893                    let make_glyph_instance =
9894                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 4]) {
9895                            if has_linear {
9896                                let gc_x = gx + gw * 0.5;
9897                                let gc_y = gy + gh * 0.5;
9898                                let wc_x = lin[0] * gc_x + lin[1] * gc_y + tr_x;
9899                                let wc_y = lin[2] * gc_x + lin[3] * gc_y + tr_y;
9900                                let ww = gw * current_transform.scale_x;
9901                                let wh = gh * current_transform.scale_y;
9902                                let ex = fwd[0].abs() * ww * 0.5 + fwd[1].abs() * wh * 0.5;
9903                                let ey = fwd[2].abs() * ww * 0.5 + fwd[3].abs() * wh * 0.5;
9904                                let ndc_tl = to_ndc(
9905                                    wc_x - ex,
9906                                    wc_y - ey,
9907                                    ex * 2.0,
9908                                    ey * 2.0,
9909                                    current_target_size.0,
9910                                    current_target_size.1,
9911                                );
9912                                let ndc = [
9913                                    ndc_tl[0] + ndc_tl[2] * 0.5,
9914                                    ndc_tl[1] + ndc_tl[3] * 0.5,
9915                                    ndc_tl[2],
9916                                    ndc_tl[3],
9917                                ];
9918                                (ndc, fwd)
9919                            } else {
9920                                let (sx, sy) = if current_transform.scale_x == 1.0
9921                                    && current_transform.scale_y == 1.0
9922                                {
9923                                    (gx.round(), gy.round())
9924                                } else {
9925                                    (gx, gy)
9926                                };
9927                                rect_to_instance_ndc(
9928                                    repose_core::Rect {
9929                                        x: sx,
9930                                        y: sy,
9931                                        w: gw,
9932                                        h: gh,
9933                                    },
9934                                    current_transform,
9935                                    current_target_size.0,
9936                                    current_target_size.1,
9937                                )
9938                            }
9939                        };
9940
9941                    let baseline_shift_y: f32 = px * baseline_shift.0;
9942
9943                    let (
9944                        draws_fill,
9945                        is_stroke,
9946                        stroke_width,
9947                        stroke_cap,
9948                        stroke_join,
9949                        stroke_miter,
9950                        stroke_path_effect,
9951                        stroke_color,
9952                    ) = match draw_style {
9953                        repose_core::DrawStyle::Stroke {
9954                            width,
9955                            cap,
9956                            join,
9957                            miter,
9958                            path_effect,
9959                        } => (
9960                            false,
9961                            true,
9962                            *width,
9963                            *cap,
9964                            *join,
9965                            *miter,
9966                            path_effect.clone(),
9967                            None,
9968                        ),
9969                        repose_core::DrawStyle::FillAndStroke {
9970                            width,
9971                            cap,
9972                            join,
9973                            miter,
9974                            path_effect,
9975                            outline_color,
9976                        } => (
9977                            true,
9978                            true,
9979                            *width,
9980                            *cap,
9981                            *join,
9982                            *miter,
9983                            path_effect.clone(),
9984                            // The outline is part of this text's appearance,
9985                            // so it fades with the text alpha like the fill.
9986                            // Without this a faded label keeps a solid halo.
9987                            outline_color.map(|c| {
9988                                c.with_alpha_f32(
9989                                    f32::from(c.3) * f32::from(color.3) / (255.0 * 255.0),
9990                                )
9991                            }),
9992                        ),
9993                        _ => (
9994                            true,
9995                            false,
9996                            0.0,
9997                            repose_core::StrokeCap::Butt,
9998                            repose_core::StrokeJoin::Miter,
9999                            4.0,
10000                            None,
10001                            None,
10002                        ),
10003                    };
10004                    // A contrasting outline reads as a halo, so it goes under
10005                    // the fill; a same-colour stroke is faux-bold and stays on
10006                    // top.
10007                    let outline_under = stroke_color.is_some();
10008                    let stroke_tess_key = if is_stroke {
10009                        Some(slug::StrokeTessKey::new(
10010                            stroke_width,
10011                            stroke_cap,
10012                            stroke_join,
10013                            stroke_miter,
10014                            &stroke_path_effect,
10015                        ))
10016                    } else {
10017                        None
10018                    };
10019
10020                    let mut legacy_shaped = None;
10021                    for (glyph_index, sg) in shaped.glyphs.iter().enumerate() {
10022                        if self.slug_enabled {
10023                            let ck = &sg.cache_key;
10024                            let color_linear = color.to_linear();
10025                            let stroke_color_linear = stroke_color.unwrap_or(*color).to_linear();
10026                            let glyph_pos = (rect.x + sg.x, rect.y + sg.y + baseline_shift_y);
10027                            let fill_key = draws_fill.then(|| {
10028                                Self::slug_draw_key(
10029                                    ck,
10030                                    true,
10031                                    None,
10032                                    current_transform,
10033                                    glyph_pos,
10034                                    px,
10035                                    current_target_size,
10036                                    color_linear,
10037                                )
10038                            });
10039                            let stroke_key = is_stroke.then(|| {
10040                                Self::slug_draw_key(
10041                                    ck,
10042                                    false,
10043                                    stroke_tess_key.as_ref(),
10044                                    current_transform,
10045                                    glyph_pos,
10046                                    px,
10047                                    current_target_size,
10048                                    stroke_color_linear,
10049                                )
10050                            });
10051
10052                            // The draw cache outlives the frame, so probing both keys up
10053                            // front lets a steady-state glyph clone neither buffer.
10054                            let fill_cache = fill_key
10055                                .as_ref()
10056                                .and_then(|k| self.get_cached_slug_vertices(k));
10057                            let stroke_cache = stroke_key
10058                                .as_ref()
10059                                .and_then(|k| self.get_cached_slug_vertices(k));
10060                            let fill_miss = fill_key.is_some() && fill_cache.is_none();
10061                            let stroke_miss = stroke_key.is_some() && stroke_cache.is_none();
10062                            if (fill_key.is_some() || stroke_key.is_some())
10063                                && !fill_miss
10064                                && !stroke_miss
10065                            {
10066                                let mut push = |cached: &Option<Arc<[slug::TessVertex]>>| {
10067                                    if let Some(vertices) = cached {
10068                                        slug_verts_local.extend_from_slice(vertices);
10069                                    }
10070                                };
10071                                if outline_under {
10072                                    push(&stroke_cache);
10073                                    push(&fill_cache);
10074                                } else {
10075                                    push(&fill_cache);
10076                                    push(&stroke_cache);
10077                                }
10078                                continue;
10079                            }
10080
10081                            let outline = self.cached_glyph_outline(ck);
10082                            if let Some(commands) = outline.as_ref() {
10083                                let font_size_px = f32::from_bits(ck.font_size_bits);
10084                                if draws_fill {
10085                                    self.slug_cache.get_or_insert(
10086                                        ck.clone(),
10087                                        font_size_px,
10088                                        commands.as_ref(),
10089                                    );
10090                                }
10091                                if is_stroke {
10092                                    self.slug_cache.get_or_insert_stroke(
10093                                        ck.clone(),
10094                                        font_size_px,
10095                                        commands.as_ref(),
10096                                        stroke_width,
10097                                        stroke_cap,
10098                                        stroke_join,
10099                                        stroke_miter,
10100                                        &stroke_path_effect,
10101                                    );
10102                                }
10103                            }
10104
10105                            if let Some(entry) = self.slug_cache.get(ck) {
10106                                let ox = rect.x + sg.x;
10107                                let oy = rect.y + sg.y + baseline_shift_y;
10108                                let tf = |x: f32, y: f32| -> (f32, f32) {
10109                                    if has_linear {
10110                                        (
10111                                            lin[0] * x + lin[1] * y + current_transform.translate_x,
10112                                            lin[2] * x + lin[3] * y + current_transform.translate_y,
10113                                        )
10114                                    } else {
10115                                        (
10116                                            x * current_transform.scale_x
10117                                                + current_transform.translate_x,
10118                                            y * current_transform.scale_y
10119                                                + current_transform.translate_y,
10120                                        )
10121                                    }
10122                                };
10123                                let tw = current_target_size.0;
10124                                let th = current_target_size.1;
10125                                let fill_vertices = if draws_fill && fill_cache.is_none() {
10126                                    entry.fill_vertices.clone().unwrap_or_default()
10127                                } else {
10128                                    Vec::new()
10129                                };
10130                                let stroke_vertices = if stroke_cache.is_none() {
10131                                    stroke_tess_key
10132                                        .as_ref()
10133                                        .and_then(|key| entry.stroke_variants.get(key))
10134                                        .map(|variant| variant.vertices.clone())
10135                                        .unwrap_or_default()
10136                                } else {
10137                                    Vec::new()
10138                                };
10139                                let mut emit =
10140                                    |verts: &Vec<[f32; 2]>,
10141                                     key: &Option<SlugDrawKey>,
10142                                     cached: &Option<Arc<[slug::TessVertex]>>,
10143                                     vertex_color: [f32; 4]| {
10144                                        if let Some(vertices) = cached {
10145                                            slug_verts_local.extend_from_slice(vertices);
10146                                            return;
10147                                        }
10148                                        let Some(key) = key.clone() else {
10149                                            return;
10150                                        };
10151                                        let transformed: Vec<slug::TessVertex> = verts
10152                                            .iter()
10153                                            .map(|v| {
10154                                                let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
10155                                                slug::TessVertex {
10156                                                    ndc_pos: [
10157                                                        sx / tw * 2.0 - 1.0,
10158                                                        -(sy / th) * 2.0 + 1.0,
10159                                                    ],
10160                                                    color: vertex_color,
10161                                                }
10162                                            })
10163                                            .collect();
10164                                        let vertices = self.cached_slug_vertices(key, transformed);
10165                                        slug_verts_local.extend_from_slice(&vertices);
10166                                    };
10167                                // A contrasting outline reads as a halo and
10168                                // goes under the fill; a same-colour stroke is
10169                                // faux-bold and stays on top.
10170                                if outline_under {
10171                                    emit(
10172                                        &stroke_vertices,
10173                                        &stroke_key,
10174                                        &stroke_cache,
10175                                        stroke_color_linear,
10176                                    );
10177                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
10178                                } else {
10179                                    emit(&fill_vertices, &fill_key, &fill_cache, color_linear);
10180                                    emit(
10181                                        &stroke_vertices,
10182                                        &stroke_key,
10183                                        &stroke_cache,
10184                                        stroke_color_linear,
10185                                    );
10186                                }
10187                                continue;
10188                            }
10189                        }
10190
10191                        if !draws_fill {
10192                            continue;
10193                        }
10194                        if legacy_shaped.is_none() {
10195                            legacy_shaped = Some(self.legacy_shape_text_cached(
10196                                text,
10197                                px,
10198                                lh_ratio,
10199                                *font_family,
10200                                fw,
10201                                fs,
10202                                letter_spacing.0,
10203                                font_variation_settings.as_ref(),
10204                            ));
10205                        }
10206                        let Some(legacy_glyph) = legacy_shaped
10207                            .as_ref()
10208                            .and_then(|glyphs| glyphs.get(glyph_index))
10209                        else {
10210                            continue;
10211                        };
10212                        let gx = rect.x + legacy_glyph.x + legacy_glyph.bearing_x;
10213                        let gy =
10214                            rect.y + legacy_glyph.y - legacy_glyph.bearing_y + baseline_shift_y;
10215                        if let Some(info) =
10216                            self.upload_glyph_color(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10217                        {
10218                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10219                            batch.colors.push(GlyphInstance {
10220                                xywh: ndc,
10221                                uv: [info.u0, info.v1, info.u1, info.v0],
10222                                color: color.to_linear(),
10223                                fwd_mat,
10224                            });
10225                        } else if let Some(info) =
10226                            self.upload_glyph_mask(legacy_glyph.cache_key.clone(), legacy_glyph.px)
10227                        {
10228                            let (ndc, fwd_mat) = make_glyph_instance(gx, gy, info.w, info.h);
10229                            batch.masks.push(GlyphInstance {
10230                                xywh: ndc,
10231                                uv: [info.u0, info.v1, info.u1, info.v0],
10232                                color: color.to_linear(),
10233                                fwd_mat,
10234                            });
10235                        }
10236                    }
10237
10238                    // Upload slug vertices if any
10239                    if !slug_verts_local.is_empty() {
10240                        let bytes = bytemuck::cast_slice(&slug_verts_local);
10241                        let byte_len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
10242                        let Ok(count) = u32::try_from(slug_verts_local.len()) else {
10243                            slug_verts_local.clear();
10244                            continue;
10245                        };
10246                        if self
10247                            .slug_ring
10248                            .grow_to_fit(&self.device, encoder, byte_len)
10249                            .is_ok()
10250                            && let Ok(off) = self.slug_ring.alloc_write(&self.queue, bytes)
10251                        {
10252                            current_pass
10253                                .cmds
10254                                .push(Cmd::GlyphsVector { off, cnt: count });
10255                        }
10256                        slug_verts_local.clear();
10257                    }
10258
10259                    // Text decoration: underline / strikethrough
10260                    if (text_decoration.underline || text_decoration.strikethrough)
10261                        && let Some(baseline_y) = baseline_y
10262                    {
10263                        flush_batch!();
10264                        current_prim = Some("rect");
10265                        let deco_color = text_decoration.color.unwrap_or(*color);
10266                        let thickness = (px * 0.07).max(1.0);
10267
10268                        if text_decoration.underline {
10269                            let dy = baseline_y + px * 0.1;
10270                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10271                                repose_core::Rect {
10272                                    x: rect.x,
10273                                    y: dy,
10274                                    w: rect.w,
10275                                    h: thickness,
10276                                },
10277                                current_transform,
10278                                current_target_size.0,
10279                                current_target_size.1,
10280                            );
10281                            batch.rects.push(RectInstance {
10282                                xywh: ndc,
10283                                radii: [0.0; 4],
10284                                color0: deco_color.to_linear(),
10285                                color1: [0.0; 4],
10286                                fwd_mat,
10287                                grad: [0.0; 4],
10288                                bits: 0,
10289                                _pad: [0.0; 3],
10290                            });
10291                        }
10292                        if text_decoration.strikethrough {
10293                            let sy = baseline_y - px * 0.3;
10294                            let (ndc, fwd_mat) = rect_to_instance_ndc(
10295                                repose_core::Rect {
10296                                    x: rect.x,
10297                                    y: sy,
10298                                    w: rect.w,
10299                                    h: thickness,
10300                                },
10301                                current_transform,
10302                                current_target_size.0,
10303                                current_target_size.1,
10304                            );
10305                            batch.rects.push(RectInstance {
10306                                xywh: ndc,
10307                                radii: [0.0; 4],
10308                                color0: deco_color.to_linear(),
10309                                color1: [0.0; 4],
10310                                fwd_mat,
10311                                grad: [0.0; 4],
10312                                bits: 0,
10313                                _pad: [0.0; 3],
10314                            });
10315                        }
10316                    }
10317                }
10318                SceneNode::Coverage {
10319                    rect,
10320                    handle,
10321                    color,
10322                } => {
10323                    flush_batch!();
10324                    // Unknown handles are skipped (same policy as images);
10325                    // the lookup also marks the tile used for eviction.
10326                    let Some((tile_w, tile_h)) = self.coverage_dimensions(*handle) else {
10327                        log::warn!("Coverage handle {handle} not found");
10328                        continue;
10329                    };
10330                    // The tile composites at its registered size; `rect`
10331                    // positions its top-left.
10332                    let draw_rect = repose_core::Rect {
10333                        x: rect.x,
10334                        y: rect.y,
10335                        w: tile_w as f32,
10336                        h: tile_h as f32,
10337                    };
10338                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10339                    if !visible(affine_aabb(current_transform, &draw_rect), clip) {
10340                        continue;
10341                    }
10342                    let (ndc_center, fwd_mat) = rect_to_instance_ndc(
10343                        draw_rect,
10344                        current_transform,
10345                        current_target_size.0,
10346                        current_target_size.1,
10347                    );
10348                    let inst = GlyphInstance {
10349                        xywh: ndc_center,
10350                        uv: [0.0, 1.0, 1.0, 0.0],
10351                        color: color.to_linear(),
10352                        fwd_mat,
10353                    };
10354                    if let Some((off, _)) =
10355                        self.glyph_color
10356                            .upload(&self.device, &self.queue, encoder, &[inst])
10357                    {
10358                        current_pass.cmds.push(Cmd::Coverage {
10359                            off,
10360                            cnt: 1,
10361                            handle: *handle,
10362                        });
10363                    }
10364                }
10365                SceneNode::PushClip { rect, radius, op } => {
10366                    flush_batch!();
10367
10368                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
10369                    let t_identity = Transform::identity();
10370                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10371                    let transformed = affine_aabb(current_transform, rect);
10372                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10373                    let next_scissor = if is_diff {
10374                        top
10375                    } else {
10376                        intersect(top, transformed)
10377                    };
10378                    scissor_stack.push(next_scissor);
10379                    let scissor = to_scissor(
10380                        &next_scissor,
10381                        current_target_size.0 as u32,
10382                        current_target_size.1 as u32,
10383                    );
10384                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
10385                    let mut blocked =
10386                        current_pass.active_scissor.is_none() || (has_inverse && !is_diff);
10387                    if has_inverse && is_diff {
10388                        log::error!(
10389                            "unsupported clip nesting: Difference cannot contain a Difference clip"
10390                        );
10391                        blocked = true;
10392                    }
10393                    let has_area = transformed.x.is_finite()
10394                        && transformed.y.is_finite()
10395                        && transformed.w.is_finite()
10396                        && transformed.h.is_finite()
10397                        && transformed.w > 0.0
10398                        && transformed.h > 0.0;
10399                    let mut applied = false;
10400                    let mut off = 0;
10401                    if !blocked && has_area && (is_diff || scissor.2 > 0 && scissor.3 > 0) {
10402                        let clip_ndc_tl = to_ndc(
10403                            transformed.x,
10404                            transformed.y,
10405                            transformed.w,
10406                            transformed.h,
10407                            current_target_size.0,
10408                            current_target_size.1,
10409                        );
10410                        let inst = ClipInstance {
10411                            xywh: [
10412                                clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
10413                                clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
10414                                clip_ndc_tl[2],
10415                                clip_ndc_tl[3],
10416                            ],
10417                            radii: radius.map(|r| r.0),
10418                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10419                        };
10420                        let bytes = bytemuck::bytes_of(&inst);
10421                        if self
10422                            .clip_ring
10423                            .grow_to_fit(
10424                                &self.device,
10425                                encoder,
10426                                u64::try_from(bytes.len()).unwrap_or(u64::MAX),
10427                            )
10428                            .is_ok()
10429                            && let Ok(new_off) = self.clip_ring.alloc_write(&self.queue, bytes)
10430                        {
10431                            off = new_off;
10432                            applied = true;
10433                            current_pass.cmds.push(Cmd::ClipPush {
10434                                off,
10435                                cnt: 1,
10436                                scissor,
10437                                difference: is_diff,
10438                                applied,
10439                            });
10440                        } else {
10441                            blocked = !is_diff;
10442                        }
10443                    } else if !is_diff {
10444                        blocked = true;
10445                    }
10446                    current_pass.active_scissor = if blocked {
10447                        None
10448                    } else {
10449                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
10450                    };
10451                    active_clips.push(ActiveClip::Rect {
10452                        off,
10453                        cnt: u32::from(applied),
10454                        rect: transformed,
10455                        radii: radius.map(|r| r.0),
10456                        scissor,
10457                        difference: is_diff,
10458                        applied,
10459                        blocked,
10460                    });
10461                }
10462                SceneNode::PopClip => {
10463                    flush_batch!();
10464
10465                    if scissor_stack.is_empty() || active_clips.is_empty() {
10466                        log::error!("PopClip does not match an active clip");
10467                    } else {
10468                        scissor_stack.pop();
10469                    }
10470                    let clip = active_clips.pop();
10471                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10472                    let scissor = to_scissor(
10473                        &top,
10474                        current_target_size.0 as u32,
10475                        current_target_size.1 as u32,
10476                    );
10477                    current_pass.active_scissor =
10478                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
10479                    match clip {
10480                        Some(ActiveClip::Rect {
10481                            off,
10482                            cnt,
10483                            scissor: stencil_scissor,
10484                            difference,
10485                            applied,
10486                            ..
10487                        }) if applied && cnt > 0 => {
10488                            current_pass.cmds.push(Cmd::ClipPop {
10489                                off,
10490                                cnt,
10491                                scissor,
10492                                stencil_scissor,
10493                                difference,
10494                                applied: true,
10495                            });
10496                        }
10497                        Some(ActiveClip::Vector { .. }) => {
10498                            log::error!("PopClip matched a vector clip");
10499                        }
10500                        None => {}
10501                        _ => {}
10502                    }
10503                }
10504                SceneNode::Shadow {
10505                    rect,
10506                    radius,
10507                    elevation: _,
10508                    color,
10509                } => {
10510                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10511                    if !visible(affine_aabb(current_transform, rect), clip) {
10512                        continue;
10513                    }
10514                    flush_if_prim_changed!("rect", &self.rects);
10515                    let (ndc, fwd_mat) = rect_to_instance_ndc(
10516                        *rect,
10517                        current_transform,
10518                        current_target_size.0,
10519                        current_target_size.1,
10520                    );
10521                    let (_brush_type, color0, _color1, _grad_p0, _grad_p1) =
10522                        brush_to_instance_fields(&Brush::Solid(*color));
10523                    batch.rects.push(RectInstance {
10524                        xywh: ndc,
10525                        radii: radius.map(|r| r.0),
10526                        color0,
10527                        color1: [0.0; 4],
10528                        fwd_mat,
10529                        grad: [0.0; 4],
10530                        bits: 0,
10531                        _pad: [0.0; 3],
10532                    });
10533                }
10534                SceneNode::PushTransform { transform } => {
10535                    flush_batch!(); // flush before transform change
10536                    if transform.has_perspective() {
10537                        // True perspective cannot ride the affine fast path:
10538                        // flatten the subtree into an offscreen layer and
10539                        // composite it back projectively (CSS-style). See
10540                        // `push_perspective_layer`.
10541                        let top = *transform_stack.last().unwrap_or(&t_identity);
10542                        self.push_perspective_layer(
10543                            *transform,
10544                            top,
10545                            &mut transform_stack,
10546                            &mut scissor_stack,
10547                            &mut root_clip_rect,
10548                            &mut current_target_size,
10549                            &mut current_pass,
10550                            &mut active_clips,
10551                            &mut passes,
10552                            &mut target_stack,
10553                            &mut flatten_stack,
10554                            &mut flatten_id_head,
10555                            &mut next_pass_id,
10556                            &mut flatten_ids_used,
10557                            encoder,
10558                        );
10559                    } else {
10560                        let combined = current_transform.combine(transform);
10561                        transform_stack.push(combined);
10562                    }
10563                }
10564                SceneNode::PopTransform => {
10565                    flush_batch!(); // flush before transform change
10566                    if let Some(rec) = flatten_stack.last() {
10567                        // A flatten level closes when the stack is back to the
10568                        // two entries this flatten pushed (stripped transform +
10569                        // layer-local shift); deeper plain pushes close first.
10570                        if transform_stack.len() == rec.stack_len + 2 {
10571                            let rec = flatten_stack.pop().expect("checked above");
10572                            transform_stack.pop();
10573                            transform_stack.pop();
10574                            self.pop_perspective_layer(
10575                                rec,
10576                                &mut scissor_stack,
10577                                &mut root_clip_rect,
10578                                &mut current_target_size,
10579                                &mut current_pass,
10580                                &mut active_clips,
10581                                &mut passes,
10582                                &mut target_stack,
10583                                &mut next_pass_id,
10584                                encoder,
10585                            );
10586                            continue;
10587                        }
10588                    }
10589                    transform_stack.pop();
10590                }
10591                SceneNode::BeginLayer {
10592                    rect,
10593                    layer_id,
10594                    alpha,
10595                    blur_radius_x,
10596                    blur_radius_y,
10597                    rectangle_edge,
10598                } => {
10599                    flush_batch!();
10600                    let width_f = rect.w.round();
10601                    let height_f = rect.h.round();
10602                    let max_dimension = self.device.limits().max_texture_dimension_2d as f32;
10603                    if !width_f.is_finite()
10604                        || !height_f.is_finite()
10605                        || width_f < 1.0
10606                        || height_f < 1.0
10607                        || width_f > max_dimension
10608                        || height_f > max_dimension
10609                    {
10610                        log::warn!("BeginLayer dimensions are invalid");
10611                        continue;
10612                    }
10613                    let width = width_f as u32;
10614                    let height = height_f as u32;
10615                    let parent_scissors =
10616                        std::mem::replace(&mut scissor_stack, Vec::with_capacity(8));
10617                    let parent_root_clip = std::mem::replace(
10618                        &mut root_clip_rect,
10619                        repose_core::Rect {
10620                            x: 0.0,
10621                            y: 0.0,
10622                            w: width as f32,
10623                            h: height as f32,
10624                        },
10625                    );
10626                    scissor_stack.push(root_clip_rect);
10627                    let layer_pass_id = next_pass_id;
10628                    next_pass_id += 1;
10629                    let parent_clips = std::mem::take(&mut active_clips);
10630                    let mut layer_pass = Pass {
10631                        id: layer_pass_id,
10632                        target: PassTarget::Layer(*layer_id),
10633                        initial_scissor: (0, 0, width, height),
10634                        active_scissor: Some((0, 0, width, height)),
10635                        clear_color: Some([0.0, 0.0, 0.0, 0.0]),
10636                        cmds: Vec::new(),
10637                    };
10638                    let parent_transform = *current_transform;
10639                    active_clips = self.replay_active_clips(
10640                        &parent_clips,
10641                        &parent_transform,
10642                        (rect.x, rect.y),
10643                        (width as f32, height as f32),
10644                        &mut layer_pass,
10645                        encoder,
10646                    );
10647                    let saved = std::mem::replace(&mut current_pass, layer_pass);
10648                    if let Some(top) = transform_stack.last_mut() {
10649                        *top = Transform::identity();
10650                    }
10651                    layer_stack.push(LayerState {
10652                        layer_id: *layer_id,
10653                        parent_target: saved.target,
10654                        parent_scissors,
10655                        parent_root_clip,
10656                        parent_size: current_target_size,
10657                        parent_transform,
10658                        parent_scissor: saved.active_scissor,
10659                        parent_clips,
10660                        alpha: *alpha,
10661                        blur: (blur_radius_x.0, blur_radius_y.0),
10662                        rectangle_edge: *rectangle_edge,
10663                    });
10664                    passes.push(saved);
10665                    if self.get_or_create_layer(*layer_id, width, height, *rect, false) {
10666                        self.producer_layer_ids.insert(*layer_id);
10667                    }
10668                    current_target_size = (width as f32, height as f32);
10669                }
10670                SceneNode::EndLayer { layer_id } => {
10671                    flush_batch!();
10672                    if layer_stack.last().map(|state| state.layer_id) != Some(*layer_id) {
10673                        log::warn!("EndLayer {} does not match active layer", layer_id);
10674                        continue;
10675                    }
10676                    let state = layer_stack.pop().expect("checked above");
10677                    scissor_stack = state.parent_scissors;
10678                    root_clip_rect = state.parent_root_clip;
10679                    active_clips = state.parent_clips;
10680                    if let Some(top) = transform_stack.last_mut() {
10681                        *top = state.parent_transform;
10682                    }
10683                    current_target_size = state.parent_size;
10684                    let resumed_pass_id = next_pass_id;
10685                    next_pass_id += 1;
10686                    let saved = std::mem::replace(
10687                        &mut current_pass,
10688                        Pass {
10689                            id: resumed_pass_id,
10690                            target: state.parent_target,
10691                            initial_scissor: state.parent_scissor.unwrap_or((0, 0, 1, 1)),
10692                            active_scissor: state.parent_scissor,
10693                            clear_color: None,
10694                            cmds: Vec::new(),
10695                        },
10696                    );
10697                    passes.push(saved);
10698                    let Some(layer) = self.layer_pool.get(layer_id).cloned() else {
10699                        continue;
10700                    };
10701                    let layer_rect = repose_core::Rect {
10702                        x: layer.rect_px.0,
10703                        y: layer.rect_px.1,
10704                        w: layer.rect_px.2,
10705                        h: layer.rect_px.3,
10706                    };
10707                    let local_layer_rect = affine_aabb(&state.parent_transform, &layer_rect);
10708                    let (parent_width, parent_height) = state.parent_size;
10709                    let parent_clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10710                    let composite_rect = if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10711                        let pad_x = blur_pad(state.blur.0);
10712                        let pad_y = blur_pad(state.blur.1);
10713                        repose_core::Rect {
10714                            x: local_layer_rect.x - pad_x,
10715                            y: local_layer_rect.y - pad_y,
10716                            w: local_layer_rect.w + pad_x * 2.0,
10717                            h: local_layer_rect.h + pad_y * 2.0,
10718                        }
10719                    } else {
10720                        local_layer_rect
10721                    };
10722                    let shadow_follows = shadow_layer_ids.contains(layer_id);
10723                    if !visible(composite_rect, parent_clip) {
10724                        continue;
10725                    }
10726                    let mut composite = None;
10727                    if state.blur.0 > 0.0 || state.blur.1 > 0.0 {
10728                        flush_batch!();
10729                        let pad_x = blur_pad(state.blur.0);
10730                        let pad_y = blur_pad(state.blur.1);
10731                        let rect = repose_core::Rect {
10732                            x: local_layer_rect.x - pad_x,
10733                            y: local_layer_rect.y - pad_y,
10734                            w: local_layer_rect.w + pad_x * 2.0,
10735                            h: local_layer_rect.h + pad_y * 2.0,
10736                        };
10737                        let ndc =
10738                            to_ndc(rect.x, rect.y, rect.w, rect.h, parent_width, parent_height);
10739                        let edge_mode = if state.rectangle_edge { 0 } else { 1 };
10740                        let sigma = [blur_sigma(state.blur.0), blur_sigma(state.blur.1)];
10741                        if let Some(scratch_id) = self.emit_separable_blur(
10742                            *layer_id,
10743                            BLUR_SLOT_CONTENT,
10744                            sigma,
10745                            (pad_x, pad_y),
10746                            edge_mode,
10747                            false,
10748                            [0.0; 4],
10749                            &mut passes,
10750                            &mut current_pass,
10751                            &mut next_pass_id,
10752                            encoder,
10753                        ) {
10754                            let inst = BlurInstance {
10755                                xywh: [
10756                                    ndc[0] + ndc[2] * 0.5,
10757                                    ndc[1] + ndc[3] * 0.5,
10758                                    ndc[2],
10759                                    ndc[3],
10760                                ],
10761                                uv: [0.0, 0.0, 1.0, 1.0],
10762                                color: [1.0, 1.0, 1.0, state.alpha],
10763                                sigma_px: sigma,
10764                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10765                                axis: 1,
10766                                edge_mode,
10767                                shape: [0.0; 4],
10768                            };
10769                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10770                                composite = Some(Cmd::CompositeBlur {
10771                                    off,
10772                                    cnt: 1,
10773                                    layer_id: scratch_id,
10774                                });
10775                            }
10776                        }
10777                    }
10778                    // No scratch (transient budget exhausted) leaves the layer
10779                    // sharp rather than dropped.
10780                    if composite.is_none() {
10781                        let ndc = to_ndc(
10782                            local_layer_rect.x,
10783                            local_layer_rect.y,
10784                            local_layer_rect.w,
10785                            local_layer_rect.h,
10786                            parent_width,
10787                            parent_height,
10788                        );
10789                        let inst = GlyphInstance {
10790                            xywh: [ndc[0] + ndc[2] * 0.5, ndc[1] + ndc[3] * 0.5, ndc[2], ndc[3]],
10791                            uv: [0.0, 1.0, 1.0, 0.0],
10792                            color: [1.0, 1.0, 1.0, state.alpha],
10793                            fwd_mat: [1.0, 0.0, 0.0, 1.0],
10794                        };
10795                        composite = self
10796                            .glyph_color
10797                            .upload(&self.device, &self.queue, encoder, &[inst])
10798                            .map(|(off, cnt)| Cmd::CompositeLayer {
10799                                off,
10800                                cnt,
10801                                layer_id: *layer_id,
10802                            });
10803                    }
10804                    if let Some(command) = composite {
10805                        if shadow_follows {
10806                            pending_layer_composite = Some(command);
10807                        } else {
10808                            current_pass.cmds.push(command);
10809                        }
10810                    }
10811                }
10812                SceneNode::CompositeShadow {
10813                    layer_id,
10814                    blur_px,
10815                    offset_px,
10816                    color,
10817                    shape_px,
10818                } => {
10819                    let sigma = blur_sigma(blur_px.0);
10820                    let pad = blur_pad(blur_px.0);
10821                    let layer = self.layer_pool.get(layer_id).cloned();
10822                    if let Some(layer) = layer {
10823                        let layer_rect = repose_core::Rect {
10824                            x: layer.rect_px.0,
10825                            y: layer.rect_px.1,
10826                            w: layer.rect_px.2,
10827                            h: layer.rect_px.3,
10828                        };
10829                        let local_layer_rect =
10830                            affine_aabb(transform_stack.last().unwrap_or(&t_identity), &layer_rect);
10831                        let sx = local_layer_rect.x + offset_px.0.0 - pad;
10832                        let sy = local_layer_rect.y + offset_px.1.0 - pad;
10833                        let sw = local_layer_rect.w + pad * 2.0;
10834                        let sh = local_layer_rect.h + pad * 2.0;
10835                        let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10836                        if !visible(
10837                            repose_core::Rect {
10838                                x: sx,
10839                                y: sy,
10840                                w: sw,
10841                                h: sh,
10842                            },
10843                            clip,
10844                        ) {
10845                            flush_pending_layer!();
10846                            continue;
10847                        }
10848                        flush_batch!();
10849                        let shadow_color = color.to_linear();
10850                        let max_r = 0.5 * layer.rect_px.2.min(layer.rect_px.3).max(0.0);
10851                        let shape = shape_px.map(|r| r.0.clamp(0.0, max_r));
10852                        // Decal edges: a drop shadow's falloff lives outside the
10853                        // source's own bounds, so out-of-range taps must
10854                        // contribute nothing rather than clamp to the edge texel.
10855                        let scratch_id = self.emit_separable_blur(
10856                            *layer_id,
10857                            BLUR_SLOT_SHADOW,
10858                            [sigma, sigma],
10859                            (pad, pad),
10860                            1,
10861                            true,
10862                            shape,
10863                            &mut passes,
10864                            &mut current_pass,
10865                            &mut next_pass_id,
10866                            encoder,
10867                        );
10868                        if let Some(scratch_id) = scratch_id {
10869                            let ndc_tl = to_ndc(
10870                                sx,
10871                                sy,
10872                                sw,
10873                                sh,
10874                                current_target_size.0,
10875                                current_target_size.1,
10876                            );
10877                            let inst = BlurInstance {
10878                                xywh: [
10879                                    ndc_tl[0] + ndc_tl[2] * 0.5,
10880                                    ndc_tl[1] + ndc_tl[3] * 0.5,
10881                                    ndc_tl[2],
10882                                    ndc_tl[3],
10883                                ],
10884                                uv: [0.0, 0.0, 1.0, 1.0],
10885                                color: shadow_color,
10886                                sigma_px: [sigma, sigma],
10887                                fwd_mat: [1.0, 0.0, 0.0, 1.0],
10888                                axis: 1,
10889                                edge_mode: 1,
10890                                shape: [0.0; 4],
10891                            };
10892                            if let Some(off) = self.upload_blur_instance(&inst, encoder) {
10893                                current_pass.cmds.push(Cmd::CompositeShadow {
10894                                    off,
10895                                    cnt: 1,
10896                                    layer_id: scratch_id,
10897                                });
10898                            }
10899                        }
10900                    }
10901                    flush_pending_layer!();
10902                }
10903                SceneNode::VectorMesh {
10904                    mesh,
10905                    transform,
10906                    paint,
10907                    clip: _,
10908                    blend,
10909                } => {
10910                    let affine = combine_mesh_affine(current_transform, *transform);
10911                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10912                    if !visible(mesh_aabb(mesh, affine), clip) {
10913                        continue;
10914                    }
10915                    flush_batch!();
10916                    let needs_isolated = mesh_blend_needs_isolation(*blend, mesh, paint);
10917                    if needs_isolated {
10918                        let blend_scissor = to_scissor(
10919                            &scissor_stack.last().copied().unwrap_or(root_clip_rect),
10920                            current_target_size.0 as u32,
10921                            current_target_size.1 as u32,
10922                        );
10923                        self.emit_isolated_blend(
10924                            mesh.clone(),
10925                            *transform,
10926                            *paint,
10927                            *blend,
10928                            current_transform,
10929                            &mut current_pass,
10930                            &active_clips,
10931                            &mut passes,
10932                            &mut flatten_id_head,
10933                            &mut next_pass_id,
10934                            &mut flatten_ids_used,
10935                            blend_scissor,
10936                            encoder,
10937                            fb_w,
10938                            fb_h,
10939                        );
10940                    } else {
10941                        let t_identity = Transform::identity();
10942                        let current_transform = transform_stack.last().unwrap_or(&t_identity);
10943                        self.emit_vector_mesh(
10944                            current_transform,
10945                            mesh,
10946                            *transform,
10947                            paint,
10948                            *blend,
10949                            &mut current_pass.cmds,
10950                        );
10951                    }
10952                }
10953                SceneNode::VectorMeshInstanced {
10954                    mesh,
10955                    transforms,
10956                    paint,
10957                    blend,
10958                } => {
10959                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
10960                    let t_identity = Transform::identity();
10961                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
10962                    // Cull per placement: an off-screen instance contributes
10963                    // nothing and never reaches the instance buffer.
10964                    let live: Vec<[f32; 6]> = transforms
10965                        .iter()
10966                        .copied()
10967                        .filter(|transform| {
10968                            let affine = combine_mesh_affine(current_transform, *transform);
10969                            visible(mesh_aabb(mesh, affine), clip)
10970                        })
10971                        .collect();
10972                    if live.is_empty() {
10973                        continue;
10974                    }
10975                    flush_batch!();
10976                    if mesh_blend_needs_isolation(*blend, mesh, paint) {
10977                        // Each of these needs its own isolated source layer, so
10978                        // batching them would not save anything.
10979                        for transform in &live {
10980                            self.emit_vector_mesh(
10981                                current_transform,
10982                                mesh,
10983                                *transform,
10984                                paint,
10985                                *blend,
10986                                &mut current_pass.cmds,
10987                            );
10988                        }
10989                    } else {
10990                        self.emit_vector_mesh_affines(
10991                            current_transform,
10992                            mesh,
10993                            &live,
10994                            paint,
10995                            *blend,
10996                            &mut current_pass.cmds,
10997                        );
10998                    }
10999                }
11000                SceneNode::VectorOverlay { meshes } => {
11001                    let clip = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11002                    let has_visible = meshes
11003                        .iter()
11004                        .any(|mesh| visible(mesh_aabb(mesh, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip));
11005                    if !has_visible {
11006                        continue;
11007                    }
11008                    flush_batch!();
11009                    for (index, m) in meshes.iter().enumerate() {
11010                        if !visible(mesh_aabb(m, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), clip) {
11011                            continue;
11012                        }
11013                        let Some(mesh_gpu) = self.upload_overlay_mesh(meshes, index) else {
11014                            continue;
11015                        };
11016                        let Some(uoff) = self.alloc_mesh_uniform(MeshUniform::identity()) else {
11017                            continue;
11018                        };
11019                        let Some((ioff, icnt)) = self.alloc_mesh_instances(&[IDENTITY_AFFINE])
11020                        else {
11021                            continue;
11022                        };
11023                        current_pass.cmds.push(Cmd::VectorOverlay {
11024                            mesh: mesh_gpu,
11025                            uoff,
11026                            ioff,
11027                            icnt,
11028                        });
11029                    }
11030                }
11031                SceneNode::PushVectorClip { mesh, op } => {
11032                    flush_batch!();
11033                    let difference = matches!(op, repose_core::ClipOp::Difference);
11034                    let t_identity = Transform::identity();
11035                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
11036                    let affine =
11037                        combine_mesh_affine(current_transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
11038                    let aabb = mesh_aabb(mesh, affine);
11039                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11040                    let next = if difference {
11041                        top
11042                    } else {
11043                        intersect(top, aabb)
11044                    };
11045                    scissor_stack.push(next);
11046                    let scissor = to_scissor(
11047                        &next,
11048                        current_target_size.0 as u32,
11049                        current_target_size.1 as u32,
11050                    );
11051                    let has_inverse = active_clips.iter().any(ActiveClip::difference);
11052                    let mut blocked = current_pass.active_scissor.is_none() || has_inverse;
11053                    if has_inverse {
11054                        log::error!(
11055                            "unsupported clip nesting: a Difference clip cannot contain another vector clip"
11056                        );
11057                    }
11058                    let has_area = aabb.x.is_finite()
11059                        && aabb.y.is_finite()
11060                        && aabb.w.is_finite()
11061                        && aabb.h.is_finite()
11062                        && aabb.w > 0.0
11063                        && aabb.h > 0.0;
11064                    let mut applied = false;
11065                    let mut geometry = None;
11066                    let mut uoff = 0;
11067                    let mut ioff = 0;
11068                    let mut icnt = 0;
11069                    if !blocked
11070                        && has_area
11071                        && (if difference {
11072                            visible(aabb, top)
11073                        } else {
11074                            scissor.2 > 0 && scissor.3 > 0
11075                        })
11076                    {
11077                        if let Some(mesh_gpu) = self.upload_mesh_geometry(mesh)
11078                            && let Some(slot) = self.alloc_mesh_uniform(mesh_uniform_from_paint(
11079                                &repose_core::PaintDesc::Solid,
11080                            ))
11081                            && let Some((run_off, run_cnt)) = self.alloc_mesh_instances(&[affine])
11082                        {
11083                            geometry = Some(mesh_gpu.clone());
11084                            uoff = slot;
11085                            ioff = run_off;
11086                            icnt = run_cnt;
11087                            applied = true;
11088                            current_pass.cmds.push(Cmd::VectorClipPush {
11089                                mesh: mesh_gpu,
11090                                uoff,
11091                                ioff,
11092                                icnt,
11093                                scissor,
11094                                difference,
11095                                applied,
11096                            });
11097                        } else {
11098                            blocked = !difference;
11099                        }
11100                    } else if !difference {
11101                        blocked = true;
11102                    }
11103                    current_pass.active_scissor = if blocked {
11104                        None
11105                    } else {
11106                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor)
11107                    };
11108                    active_clips.push(ActiveClip::Vector {
11109                        mesh: geometry,
11110                        source: mesh.clone(),
11111                        uoff,
11112                        ioff,
11113                        icnt,
11114                        affine,
11115                        scissor,
11116                        difference,
11117                        applied,
11118                        blocked,
11119                    });
11120                }
11121                SceneNode::PopVectorClip => {
11122                    flush_batch!();
11123                    if scissor_stack.is_empty() || active_clips.is_empty() {
11124                        log::error!("PopVectorClip does not match an active clip");
11125                    } else {
11126                        scissor_stack.pop();
11127                    }
11128                    let clip = active_clips.pop();
11129                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11130                    let scissor = to_scissor(
11131                        &top,
11132                        current_target_size.0 as u32,
11133                        current_target_size.1 as u32,
11134                    );
11135                    current_pass.active_scissor =
11136                        (scissor.2 > 0 && scissor.3 > 0).then_some(scissor);
11137                    match clip {
11138                        Some(ActiveClip::Vector {
11139                            mesh: Some(mesh),
11140                            uoff,
11141                            ioff,
11142                            icnt,
11143                            scissor: stencil_scissor,
11144                            difference,
11145                            applied,
11146                            ..
11147                        }) if applied && mesh.vertex_count > 0 && mesh.index_count > 0 => {
11148                            current_pass.cmds.push(Cmd::VectorClipPop {
11149                                mesh,
11150                                uoff,
11151                                ioff,
11152                                icnt,
11153                                scissor,
11154                                stencil_scissor,
11155                                difference,
11156                                applied: true,
11157                            });
11158                        }
11159                        Some(ActiveClip::Rect { .. }) => {
11160                            log::error!("PopVectorClip matched a rounded-rect clip");
11161                        }
11162                        None => {}
11163                        _ => {}
11164                    }
11165                }
11166                SceneNode::Callback { rect, payload } => {
11167                    let t = transform_stack
11168                        .last()
11169                        .copied()
11170                        .unwrap_or(Transform::identity());
11171                    let transformed = affine_aabb(&t, rect);
11172                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
11173                    let clip_rect = intersect(transformed, top);
11174                    let scissor = to_scissor(
11175                        &clip_rect,
11176                        current_target_size.0 as u32,
11177                        current_target_size.1 as u32,
11178                    );
11179                    let restore_scissor = to_scissor(
11180                        &top,
11181                        current_target_size.0 as u32,
11182                        current_target_size.1 as u32,
11183                    );
11184                    if current_pass.active_scissor.is_none()
11185                        || !visible(transformed, top)
11186                        || scissor.2 == 0
11187                        || scissor.3 == 0
11188                    {
11189                        continue;
11190                    }
11191                    flush_batch!();
11192                    current_pass.cmds.push(Cmd::Callback {
11193                        rect: transformed,
11194                        clip_rect,
11195                        scissor,
11196                        restore_scissor,
11197                        callback_id: Arc::as_ptr(payload) as *const () as usize,
11198                        payload: payload.clone(),
11199                    });
11200                }
11201                _ => {}
11202            }
11203        }
11204
11205        flush_pending_layer!();
11206        flush_batch!();
11207        passes.push(current_pass);
11208
11209        {
11210            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
11211            let mut prepare_list: Vec<(usize, Arc<Callback>)> = Vec::new();
11212            for pass in &passes {
11213                for command in &pass.cmds {
11214                    if let Cmd::Callback { payload, .. } = command
11215                        && payload.downcast_ref::<Callback>().is_some()
11216                    {
11217                        let ptr = Arc::as_ptr(payload) as *const () as usize;
11218                        if seen.insert(ptr)
11219                            && let Ok(cb_arc) = payload.clone().downcast::<Callback>()
11220                        {
11221                            prepare_list.push((ptr, cb_arc));
11222                        }
11223                    }
11224                }
11225            }
11226            if !prepare_list.is_empty() {
11227                let mut callback_targets: HashMap<usize, Vec<(PassTarget, ScreenDescriptor)>> =
11228                    HashMap::new();
11229                for pass in &passes {
11230                    let (size, target_format, sample_count) = match pass.target {
11231                        PassTarget::Surface => (
11232                            [self.output_width, self.output_height],
11233                            if self.working_space {
11234                                wgpu::TextureFormat::Rgba16Float
11235                            } else {
11236                                self.output_format
11237                            },
11238                            self.active_surface_msaa_samples(),
11239                        ),
11240                        PassTarget::Layer(layer_id) => {
11241                            let layer = self.layer_pool.get(&layer_id);
11242                            (
11243                                layer.map_or([self.output_width, self.output_height], |layer| {
11244                                    [layer.width, layer.height]
11245                                }),
11246                                self.layer_target_format(),
11247                                1,
11248                            )
11249                        }
11250                    };
11251                    for command in &pass.cmds {
11252                        if let Cmd::Callback { payload, .. } = command {
11253                            let descriptor = ScreenDescriptor {
11254                                size_in_pixels: size,
11255                                pixels_per_point: self.pixels_per_point,
11256                                target_format,
11257                                sample_count,
11258                            };
11259                            let targets = callback_targets
11260                                .entry(Arc::as_ptr(payload) as *const () as usize)
11261                                .or_default();
11262                            if !targets.iter().any(|(target, target_desc)| {
11263                                *target == pass.target
11264                                    && target_desc.target_format == descriptor.target_format
11265                                    && target_desc.sample_count == descriptor.sample_count
11266                            }) {
11267                                targets.push((pass.target, descriptor));
11268                            }
11269                        }
11270                    }
11271                }
11272
11273                let default_descriptor = ScreenDescriptor {
11274                    size_in_pixels: [self.output_width, self.output_height],
11275                    pixels_per_point: self.pixels_per_point,
11276                    target_format: if self.working_space {
11277                        wgpu::TextureFormat::Rgba16Float
11278                    } else {
11279                        self.output_format
11280                    },
11281                    sample_count: self.active_surface_msaa_samples(),
11282                };
11283                let default_descriptors = [(PassTarget::Surface, default_descriptor)];
11284                let mut prepare_encoder =
11285                    self.device
11286                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11287                            label: Some("callback prepare"),
11288                        });
11289                let mut finish_encoder =
11290                    self.device
11291                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
11292                            label: Some("callback finish prepare"),
11293                        });
11294                let mut active_callback_scopes = HashSet::new();
11295                for (key, cb) in &prepare_list {
11296                    let descriptors = callback_targets
11297                        .get(key)
11298                        .map(Vec::as_slice)
11299                        .unwrap_or(&default_descriptors);
11300                    for &(target, screen_desc) in descriptors {
11301                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11302                        active_callback_scopes.insert(scope.clone());
11303                        if !scope.callback.reusable
11304                            && self
11305                                .callback_scope_payloads
11306                                .get(&scope)
11307                                .is_some_and(|payload| payload.upgrade().is_none())
11308                        {
11309                            self.remove_callback_scope(&scope);
11310                        }
11311                        let payload: Weak<Callback> = Arc::downgrade(cb);
11312                        self.callback_scope_payloads.insert(scope, payload);
11313                    }
11314                }
11315                let mut prepare_buffers = Vec::new();
11316                let mut finish_buffers = Vec::new();
11317                for (key, cb) in &prepare_list {
11318                    let descriptors = callback_targets
11319                        .get(key)
11320                        .map(Vec::as_slice)
11321                        .unwrap_or(&default_descriptors);
11322                    for &(target, screen_desc) in descriptors {
11323                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11324                        self.touch_callback_scope(scope.clone());
11325                        if !self.callback_scoped_resources.contains_key(&scope)
11326                            && self.callback_scoped_resources.len() >= MAX_CALLBACK_SCOPES
11327                        {
11328                            self.evict_callback_scope(&active_callback_scopes);
11329                        }
11330                        let resources = self.callback_scoped_resources.entry(scope).or_default();
11331                        prepare_buffers.extend(cb.0.prepare(
11332                            &self.device,
11333                            &self.queue,
11334                            &mut prepare_encoder,
11335                            &screen_desc,
11336                            resources,
11337                        ));
11338                    }
11339                }
11340                for (key, cb) in &prepare_list {
11341                    let descriptors = callback_targets
11342                        .get(key)
11343                        .map(Vec::as_slice)
11344                        .unwrap_or(&default_descriptors);
11345                    for &(target, screen_desc) in descriptors {
11346                        let scope = callback_scope_key(cb, *key, target, &screen_desc);
11347                        self.touch_callback_scope(scope.clone());
11348                        if let Some(resources) = self.callback_scoped_resources.get_mut(&scope) {
11349                            finish_buffers.extend(cb.0.finish_prepare(
11350                                &self.device,
11351                                &self.queue,
11352                                &mut finish_encoder,
11353                                &screen_desc,
11354                                resources,
11355                            ));
11356                        }
11357                    }
11358                }
11359                let mut callback_buffers =
11360                    Vec::with_capacity(2 + prepare_buffers.len() + finish_buffers.len());
11361                callback_buffers.push(prepare_encoder.finish());
11362                callback_buffers.extend(prepare_buffers);
11363                callback_buffers.push(finish_encoder.finish());
11364                callback_buffers.extend(finish_buffers);
11365                self.queue.submit(callback_buffers);
11366            }
11367        }
11368
11369        let globals_bytes = std::mem::size_of::<Globals>() as u64;
11370        if !self.ensure_globals_staging(passes.len()) {
11371            return;
11372        }
11373        for (i, pass) in passes.iter().enumerate() {
11374            let (target_w, target_h) = match pass.target {
11375                PassTarget::Surface => (fb_w, fb_h),
11376                PassTarget::Layer(layer_id) => {
11377                    let lt = self.layer_pool.get(&layer_id);
11378                    (
11379                        lt.map_or(fb_w, |l| l.width as f32),
11380                        lt.map_or(fb_h, |l| l.height as f32),
11381                    )
11382                }
11383            };
11384            self.queue.write_buffer(
11385                &self.globals_staging,
11386                (i as u64) * globals_bytes,
11387                bytemuck::bytes_of(&make_globals(target_w, target_h)),
11388            );
11389        }
11390
11391        let bind_mask = self.atlas_bind_group_mask();
11392        let bind_color = self.atlas_bind_group_color();
11393        let mut clip_depth: u32 = 0;
11394        let mut clip_depth_stack: Vec<u32> = Vec::new();
11395
11396        let snapshot_source = target_texture.cloned();
11397        let last_surface_pass = passes
11398            .iter()
11399            .rposition(|pass| matches!(pass.target, PassTarget::Surface));
11400        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
11401            // Populate backdrop snapshots for blends composited in this
11402            // pass: copy the parent target region into the snapshot
11403            // texture. Passes execute in order, so the parent holds the
11404            // true backdrop. Surface parents need the target texture (no
11405            // swapchain sampling mid-frame); layer parents copy from the
11406            // pool. Either way the region is parent-target pixels. Copies
11407            // for other passes stay queued (`remaining`).
11408            let needs_snapshot = pass
11409                .cmds
11410                .iter()
11411                .any(|c| matches!(c, Cmd::BlendLayer { .. }));
11412            if needs_snapshot {
11413                let copies = std::mem::take(&mut self.blend_copies);
11414                let has_surface_copy = copies.iter().any(|copy| copy.target == PassTarget::Surface);
11415                let mut resolved_surface = None;
11416                if has_surface_copy
11417                    && !self.working_space
11418                    && let (Some(msaa_view), Some(resolve_view)) =
11419                        (self.msaa_view.as_ref(), self.surface_resolve_view.as_ref())
11420                {
11421                    let resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11422                        label: Some("surface blend resolve"),
11423                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11424                            view: msaa_view,
11425                            resolve_target: Some(resolve_view),
11426                            ops: wgpu::Operations {
11427                                load: wgpu::LoadOp::Load,
11428                                store: wgpu::StoreOp::Store,
11429                            },
11430                            depth_slice: None,
11431                        })],
11432                        depth_stencil_attachment: None,
11433                        timestamp_writes: None,
11434                        occlusion_query_set: None,
11435                        multiview_mask: None,
11436                    });
11437                    drop(resolve_pass);
11438                    resolved_surface = self.surface_resolve_tex.clone();
11439                }
11440                let mut remaining = Vec::with_capacity(copies.len());
11441                for copy in copies {
11442                    if copy.pass_id != pass.id {
11443                        remaining.push(copy);
11444                        continue;
11445                    }
11446                    let BlendCopy {
11447                        blend_id,
11448                        target,
11449                        region,
11450                        ..
11451                    } = copy;
11452                    let (src_tex, tw, th) = match target {
11453                        PassTarget::Layer(parent_id) => match self.layer_pool.get(&parent_id) {
11454                            Some(lt) => (lt.texture.clone(), lt.width, lt.height),
11455                            None => continue,
11456                        },
11457                        PassTarget::Surface => {
11458                            let texture = if self.working_space {
11459                                self.ws_tex.clone()
11460                            } else if self.msaa_view.is_some() {
11461                                resolved_surface.clone()
11462                            } else {
11463                                snapshot_source.clone()
11464                            };
11465                            match texture {
11466                                Some(texture) => (texture, self.output_width, self.output_height),
11467                                None => {
11468                                    self.remove_blend_snapshot(blend_id);
11469                                    continue;
11470                                }
11471                            }
11472                        }
11473                    };
11474                    let Some(dst_tex) = self.blend_snapshot_texture(blend_id) else {
11475                        continue;
11476                    };
11477                    if !src_tex.usage().contains(wgpu::TextureUsages::COPY_SRC)
11478                        || !dst_tex.usage().contains(wgpu::TextureUsages::COPY_DST)
11479                    {
11480                        self.remove_blend_snapshot(blend_id);
11481                        continue;
11482                    }
11483                    let Some((sx, sy, cw, ch)) = checked_copy_region(region, tw, th) else {
11484                        self.remove_blend_snapshot(blend_id);
11485                        continue;
11486                    };
11487                    encoder.copy_texture_to_texture(
11488                        wgpu::TexelCopyTextureInfo {
11489                            texture: &src_tex,
11490                            mip_level: 0,
11491                            origin: wgpu::Origin3d { x: sx, y: sy, z: 0 },
11492                            aspect: wgpu::TextureAspect::All,
11493                        },
11494                        wgpu::TexelCopyTextureInfo {
11495                            texture: &dst_tex,
11496                            mip_level: 0,
11497                            origin: wgpu::Origin3d::ZERO,
11498                            aspect: wgpu::TextureAspect::All,
11499                        },
11500                        wgpu::Extent3d {
11501                            width: cw,
11502                            height: ch,
11503                            depth_or_array_layers: 1,
11504                        },
11505                    );
11506                }
11507                self.blend_copies = remaining;
11508            }
11509            let (color_view, resolve_target, depth_stencil_view, is_layer, working_target) =
11510                match pass.target {
11511                    PassTarget::Surface => {
11512                        let swap_view = target_view.clone();
11513                        let use_ws = self.working_space && self.ws_view.is_some();
11514                        let (color, resolve) = if use_ws {
11515                            let ws_view = self.ws_view.as_ref().unwrap();
11516                            if let Some(msaa_view) = &self.ws_msaa_view {
11517                                (msaa_view.clone(), Some(ws_view.clone()))
11518                            } else {
11519                                (ws_view.clone(), None)
11520                            }
11521                        } else if let Some(msaa_view) = &self.msaa_view {
11522                            // Resolve once per frame. Graphics layers split the
11523                            // surface into several passes that all accumulate into
11524                            // the same multisampled attachment, so only the last
11525                            // one can contribute to the presented image.
11526                            let resolve =
11527                                (Some(pass_index) == last_surface_pass).then(|| swap_view.clone());
11528                            (msaa_view.clone(), resolve)
11529                        } else {
11530                            (swap_view, None)
11531                        };
11532                        (
11533                            color,
11534                            resolve,
11535                            self.depth_stencil_view.clone(),
11536                            false,
11537                            use_ws,
11538                        )
11539                    }
11540                    PassTarget::Layer(layer_id) => {
11541                        if let Some(lt) = self.layer_pool.get(&layer_id) {
11542                            (
11543                                lt.view.clone(),
11544                                None,
11545                                lt.depth_stencil_view.clone(),
11546                                true,
11547                                false,
11548                            )
11549                        } else {
11550                            log::warn!("missing layer target {layer_id}");
11551                            continue;
11552                        }
11553                    }
11554                };
11555
11556            encoder.copy_buffer_to_buffer(
11557                &self.globals_staging,
11558                (pass_index as u64) * globals_bytes,
11559                &self.globals_buf,
11560                0,
11561                globals_bytes,
11562            );
11563
11564            if is_layer {
11565                clip_depth_stack.push(clip_depth);
11566                clip_depth = 0;
11567            }
11568
11569            let (tw, th) = match pass.target {
11570                PassTarget::Surface => (self.output_width, self.output_height),
11571                PassTarget::Layer(layer_id) => self
11572                    .layer_pool
11573                    .get(&layer_id)
11574                    .map(|l| (l.width, l.height))
11575                    .unwrap_or((self.output_width, self.output_height)),
11576            };
11577            let initial_scissor = clamp_scissor(
11578                pass.initial_scissor.0,
11579                pass.initial_scissor.1,
11580                pass.initial_scissor.2,
11581                pass.initial_scissor.3,
11582                tw,
11583                th,
11584            );
11585            let mut active_scissor = pass
11586                .active_scissor
11587                .map(|scissor| clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th));
11588            if active_scissor.is_some_and(|scissor| scissor.2 == 0 || scissor.3 == 0) {
11589                active_scissor = None;
11590            }
11591
11592            let pipes: &Pipelines = if is_layer {
11593                if self.working_space {
11594                    &self.working_space_layer_pipes
11595                } else {
11596                    &self.layer_pipes
11597                }
11598            } else if working_target {
11599                &self.working_space_pipes
11600            } else {
11601                &self.surface_pipes
11602            };
11603
11604            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
11605                label: Some("pass"),
11606                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
11607                    view: &color_view,
11608                    resolve_target: resolve_target.as_ref(),
11609                    ops: wgpu::Operations {
11610                        load: match pass.clear_color {
11611                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
11612                                r: c[0] as f64,
11613                                g: c[1] as f64,
11614                                b: c[2] as f64,
11615                                a: c[3] as f64,
11616                            }),
11617                            None => wgpu::LoadOp::Load,
11618                        },
11619                        store: wgpu::StoreOp::Store,
11620                    },
11621                    depth_slice: None,
11622                })],
11623                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
11624                    view: &depth_stencil_view,
11625                    depth_ops: None,
11626                    stencil_ops: Some(wgpu::Operations {
11627                        load: if pass.clear_color.is_some() {
11628                            wgpu::LoadOp::Clear(STENCIL_BASE)
11629                        } else {
11630                            wgpu::LoadOp::Load
11631                        },
11632                        store: wgpu::StoreOp::Store,
11633                    }),
11634                }),
11635                timestamp_writes: None,
11636                occlusion_query_set: None,
11637                multiview_mask: None,
11638            });
11639
11640            rpass.set_bind_group(0, &self.globals_bind, &[]);
11641            rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11642            let initial_draw_scissor = if initial_scissor.2 == 0 || initial_scissor.3 == 0 {
11643                (0, 0, 1, 1)
11644            } else {
11645                initial_scissor
11646            };
11647            rpass.set_scissor_rect(
11648                initial_draw_scissor.0,
11649                initial_draw_scissor.1,
11650                initial_draw_scissor.2,
11651                initial_draw_scissor.3,
11652            );
11653
11654            macro_rules! draw_simple {
11655                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
11656                    rpass.set_pipeline($pipeline);
11657                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11658                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11659                    rpass.draw(0..6, 0..$n);
11660                }};
11661            }
11662
11663            macro_rules! draw_with_bind {
11664                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
11665                    rpass.set_pipeline($pipeline);
11666                    rpass.set_bind_group(1, $bind, &[]);
11667                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
11668                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
11669                    rpass.draw(0..6, 0..$n);
11670                }};
11671            }
11672
11673            macro_rules! draw_indexed_mesh {
11674                ($pipeline:expr, $uoff:ident, $mesh:ident, $ioff:ident, $icnt:ident) => {{
11675                    rpass.set_pipeline($pipeline);
11676                    rpass.set_bind_group(1, &self.mesh_bind, &[$uoff as u32]);
11677                    let vbytes =
11678                        ($mesh.vertex_count as u64) * std::mem::size_of::<MeshVertex>() as u64;
11679                    rpass.set_vertex_buffer(0, $mesh.vertices.slice(0..vbytes));
11680                    let ibytes = ($mesh.index_count as u64) * std::mem::size_of::<u32>() as u64;
11681                    rpass.set_index_buffer(
11682                        $mesh.indices.slice(0..ibytes),
11683                        wgpu::IndexFormat::Uint32,
11684                    );
11685                    let ibytes = ($icnt as u64) * std::mem::size_of::<MeshInstance>() as u64;
11686                    rpass.set_vertex_buffer(1, self.mesh_instance_buf.slice($ioff..$ioff + ibytes));
11687                    rpass.draw_indexed(0..$mesh.index_count, 0, 0..$icnt);
11688                }};
11689            }
11690
11691            for cmd in pass.cmds {
11692                if active_scissor.is_none()
11693                    && !matches!(
11694                        &cmd,
11695                        Cmd::ClipPush { .. }
11696                            | Cmd::ClipPop { .. }
11697                            | Cmd::VectorClipPush { .. }
11698                            | Cmd::VectorClipPop { .. }
11699                    )
11700                {
11701                    continue;
11702                }
11703                match cmd {
11704                    Cmd::ClipPush {
11705                        off,
11706                        cnt: n,
11707                        scissor,
11708                        difference,
11709                        applied,
11710                    } => {
11711                        let scissor =
11712                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11713                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
11714                        if usable {
11715                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11716                            active_scissor = Some(scissor);
11717                            rpass.set_pipeline(if difference {
11718                                &pipes.clip_dec
11719                            } else {
11720                                &pipes.clip_bin
11721                            });
11722
11723                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11724                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11725                            rpass.draw(0..6, 0..n);
11726                            if !difference {
11727                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
11728                            }
11729                        } else {
11730                            active_scissor = None;
11731                        }
11732                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11733                    }
11734
11735                    Cmd::ClipPop {
11736                        off,
11737                        cnt: n,
11738                        scissor,
11739                        stencil_scissor,
11740                        difference,
11741                        applied,
11742                    } => {
11743                        let scissor =
11744                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
11745                        let dec = clamp_scissor(
11746                            stencil_scissor.0,
11747                            stencil_scissor.1,
11748                            stencil_scissor.2,
11749                            stencil_scissor.3,
11750                            tw,
11751                            th,
11752                        );
11753                        if scissor.2 > 0 && scissor.3 > 0 {
11754                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11755                            active_scissor = Some(scissor);
11756                        } else {
11757                            active_scissor = None;
11758                        }
11759                        if applied && n > 0 && dec.2 > 0 && dec.3 > 0 {
11760                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
11761                            rpass.set_pipeline(if difference {
11762                                &pipes.clip_bin
11763                            } else {
11764                                &pipes.clip_dec
11765                            });
11766                            let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
11767                            rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
11768                            rpass.draw(0..6, 0..n);
11769                            if scissor.2 > 0 && scissor.3 > 0 {
11770                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
11771                            }
11772                            if !difference {
11773                                clip_depth = clip_depth.saturating_sub(1);
11774                            }
11775                        }
11776                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
11777                    }
11778
11779                    Cmd::Rect { off, cnt: n } => {
11780                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
11781                    }
11782
11783                    Cmd::Border { off, cnt: n } => {
11784                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
11785                    }
11786
11787                    Cmd::GlyphsMask { off, cnt: n } => {
11788                        draw_with_bind!(
11789                            &pipes.text_mask,
11790                            self.glyph_mask.ring,
11791                            GlyphInstance,
11792                            &bind_mask,
11793                            off,
11794                            n
11795                        );
11796                    }
11797
11798                    Cmd::GlyphsColor { off, cnt: n } => {
11799                        draw_with_bind!(
11800                            &pipes.text_color,
11801                            self.glyph_color.ring,
11802                            GlyphInstance,
11803                            &bind_color,
11804                            off,
11805                            n
11806                        );
11807                    }
11808
11809                    Cmd::GlyphsVector { off, cnt: n } => {
11810                        if let Some(slug_pipe) = pipes.slug.as_ref() {
11811                            rpass.set_pipeline(slug_pipe);
11812                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
11813                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
11814                            rpass.draw(0..n, 0..1);
11815                        }
11816                    }
11817
11818                    Cmd::ImageRgba {
11819                        off,
11820                        cnt: n,
11821                        handle,
11822                        filter,
11823                        tile,
11824                        blend,
11825                    } => {
11826                        let bind_opt = match self.images.get(&handle) {
11827                            Some(ImageTex::Rgba { binds, .. }) => Some(binds.get(filter, tile)),
11828                            Some(ImageTex::User { binds, .. }) => Some(binds.get(filter, tile)),
11829                            _ => None,
11830                        };
11831                        if let Some(bind) = bind_opt {
11832                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11833                            {
11834                                Some(index) => &pipes.image_blends[index],
11835                                None => &pipes.image_rgba,
11836                            };
11837                            draw_with_bind!(
11838                                pipe,
11839                                self.glyph_color.ring,
11840                                GlyphInstance,
11841                                bind,
11842                                off,
11843                                n
11844                            );
11845                        }
11846                    }
11847                    Cmd::Coverage {
11848                        off,
11849                        cnt: n,
11850                        handle,
11851                    } => {
11852                        if let Some(tile) = self.coverages.get(&handle) {
11853                            draw_with_bind!(
11854                                &pipes.coverage,
11855                                self.glyph_color.ring,
11856                                GlyphInstance,
11857                                &tile.bind,
11858                                off,
11859                                n
11860                            );
11861                        }
11862                    }
11863
11864                    Cmd::ImageNv12 {
11865                        off,
11866                        cnt: n,
11867                        handle,
11868                        filter,
11869                        blend,
11870                    } => {
11871                        if let Some(ImageTex::Nv12 { binds, .. }) = self.images.get(&handle) {
11872                            let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend)
11873                            {
11874                                Some(index) => &pipes.image_nv12_blends[index],
11875                                None => &pipes.image_nv12,
11876                            };
11877                            draw_with_bind!(
11878                                pipe,
11879                                self.nv12.ring,
11880                                Nv12Instance,
11881                                binds.get(filter, false),
11882                                off,
11883                                n
11884                            );
11885                        }
11886                    }
11887
11888                    Cmd::Ellipse { off, cnt: n } => {
11889                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
11890                    }
11891
11892                    Cmd::EllipseBorder { off, cnt: n } => {
11893                        draw_simple!(
11894                            &pipes.ellipse_borders,
11895                            self.ellipse_borders.ring,
11896                            EllipseBorderInstance,
11897                            off,
11898                            n
11899                        );
11900                    }
11901
11902                    Cmd::Arc { off, cnt: n } => {
11903                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
11904                    }
11905
11906                    Cmd::CompositeLayer {
11907                        off,
11908                        cnt: n,
11909                        layer_id,
11910                    } => {
11911                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11912                            draw_with_bind!(
11913                                &pipes.image_rgba,
11914                                self.glyph_color.ring,
11915                                GlyphInstance,
11916                                &lt.bind,
11917                                off,
11918                                n
11919                            );
11920                        }
11921                    }
11922                    Cmd::CompositeShadow {
11923                        off,
11924                        cnt: n,
11925                        layer_id,
11926                    } => {
11927                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11928                            draw_with_bind!(
11929                                &pipes.blur,
11930                                self.blur_ring,
11931                                BlurInstance,
11932                                &lt.bind_linear,
11933                                off,
11934                                n
11935                            );
11936                        }
11937                    }
11938                    Cmd::CompositeBlur {
11939                        off,
11940                        cnt: n,
11941                        layer_id,
11942                    } => {
11943                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11944                            draw_with_bind!(
11945                                &pipes.blur_content,
11946                                self.blur_ring,
11947                                BlurInstance,
11948                                &lt.bind_linear,
11949                                off,
11950                                n
11951                            );
11952                        }
11953                    }
11954                    Cmd::CompositeProjective {
11955                        off,
11956                        cnt: n,
11957                        layer_id,
11958                    } => {
11959                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
11960                            // The layer texture is sampled with the rgba
11961                            // (non-linear-filter) binding, like the sharp
11962                            // composite path.
11963                            draw_with_bind!(
11964                                &pipes.projective_layer,
11965                                self.projective_ring,
11966                                ProjectiveInstance,
11967                                &lt.bind,
11968                                off,
11969                                n
11970                            );
11971                        }
11972                    }
11973
11974                    Cmd::BlendLayer {
11975                        off,
11976                        cnt: n,
11977                        src_layer,
11978                        dst_layer,
11979                        parent,
11980                        scissor,
11981                    } => {
11982                        let src = self.layer_pool.get(&src_layer).cloned();
11983                        // Backdrop is the snapshot copied from the parent
11984                        // target before this pass opened (a texture cannot be
11985                        // sampled mid-pass while bound as a render target).
11986                        // A missing snapshot (surface parent without texture,
11987                        // evicted layer) skips the draw: the mesh vanishes
11988                        // rather than misrendering.
11989                        let dst = dst_layer.and_then(|id| self.blend_snapshots.get(&id).cloned());
11990                        let in_parent = match parent {
11991                            PassTarget::Surface => !is_layer,
11992                            PassTarget::Layer(id) => {
11993                                matches!(pass.target, PassTarget::Layer(pid) if pid == id)
11994                            }
11995                        };
11996                        if let (Some(src_lt), Some(dst_snap)) = (src, dst)
11997                            && in_parent
11998                        {
11999                            let bytes = (n as u64) * std::mem::size_of::<BlendInstance>() as u64;
12000                            rpass.set_pipeline(&pipes.blend_layer);
12001                            let scissor =
12002                                clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12003                            if scissor.2 > 0 && scissor.3 > 0 {
12004                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12005                                rpass.set_bind_group(0, &self.globals_bind, &[]);
12006                                rpass.set_bind_group(1, &src_lt.bind, &[]);
12007                                rpass.set_bind_group(2, &dst_snap.bind, &[]);
12008                                rpass.set_vertex_buffer(
12009                                    0,
12010                                    self.blend_ring.buf.slice(off..off + bytes),
12011                                );
12012                                rpass.draw(0..6, 0..n);
12013                            }
12014                        }
12015                    }
12016
12017                    Cmd::VectorMesh {
12018                        mesh,
12019                        uoff,
12020                        ioff,
12021                        icnt,
12022                        blend,
12023                    } => {
12024                        let pipe = match FIXED_FUNCTION_BLENDS.iter().position(|m| *m == blend) {
12025                            Some(index) => &pipes.mesh_blends[index],
12026                            None => &pipes.mesh,
12027                        };
12028                        draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12029                    }
12030
12031                    Cmd::VectorOverlay {
12032                        mesh,
12033                        uoff,
12034                        ioff,
12035                        icnt,
12036                    } => {
12037                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12038                        draw_indexed_mesh!(&pipes.mesh_overlay, uoff, mesh, ioff, icnt);
12039                    }
12040
12041                    Cmd::VectorClipPush {
12042                        mesh,
12043                        uoff,
12044                        ioff,
12045                        icnt,
12046                        scissor,
12047                        difference,
12048                        applied,
12049                    } => {
12050                        let scissor =
12051                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12052                        let usable = applied && scissor.2 > 0 && scissor.3 > 0;
12053                        if usable {
12054                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12055                            active_scissor = Some(scissor);
12056                            let pipe = if difference {
12057                                &pipes.mesh_clip_dec
12058                            } else {
12059                                &pipes.mesh_clip_inc
12060                            };
12061                            draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12062                            if !difference {
12063                                clip_depth = (clip_depth + 1).min(STENCIL_MAX_DEPTH);
12064                            }
12065                        } else {
12066                            active_scissor = None;
12067                        }
12068                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12069                    }
12070
12071                    Cmd::VectorClipPop {
12072                        mesh,
12073                        uoff,
12074                        ioff,
12075                        icnt,
12076                        scissor,
12077                        stencil_scissor,
12078                        difference,
12079                        applied,
12080                    } => {
12081                        let scissor =
12082                            clamp_scissor(scissor.0, scissor.1, scissor.2, scissor.3, tw, th);
12083                        let dec = clamp_scissor(
12084                            stencil_scissor.0,
12085                            stencil_scissor.1,
12086                            stencil_scissor.2,
12087                            stencil_scissor.3,
12088                            tw,
12089                            th,
12090                        );
12091                        if scissor.2 > 0 && scissor.3 > 0 {
12092                            rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12093                            active_scissor = Some(scissor);
12094                        } else {
12095                            active_scissor = None;
12096                        }
12097                        if applied && dec.2 > 0 && dec.3 > 0 {
12098                            rpass.set_scissor_rect(dec.0, dec.1, dec.2, dec.3);
12099                            let pipe = if difference {
12100                                &pipes.mesh_clip_inc
12101                            } else {
12102                                &pipes.mesh_clip_dec
12103                            };
12104                            draw_indexed_mesh!(pipe, uoff, mesh, ioff, icnt);
12105                            if scissor.2 > 0 && scissor.3 > 0 {
12106                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12107                            }
12108                            if !difference {
12109                                clip_depth = clip_depth.saturating_sub(1);
12110                            }
12111                        }
12112                        rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12113                    }
12114
12115                    Cmd::Callback {
12116                        rect,
12117                        clip_rect,
12118                        scissor,
12119                        restore_scissor,
12120                        callback_id,
12121                        payload,
12122                    } => {
12123                        if let Some(cb) = payload.downcast_ref::<Callback>() {
12124                            // Round INWARD. A callback may only paint inside its
12125                            // own rect, and a fullscreen pass draws a single
12126                            // clip-space quad spanning twice the viewport, so an
12127                            // outward-rounded viewport overshot the rect and the
12128                            // outward-rounded scissor let that overshoot through.
12129                            let vp_x = rect.x.ceil().max(0.0);
12130                            let vp_y = rect.y.ceil().max(0.0);
12131                            let vp_w = ((rect.x + rect.w).floor() - vp_x).max(1.0);
12132                            let vp_h = ((rect.y + rect.h).floor() - vp_y).max(1.0);
12133                            if vp_w > 0.0 && vp_h > 0.0 && clip_rect.w > 0.0 && clip_rect.h > 0.0 {
12134                                rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
12135                                rpass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
12136                                let info = repose_core::PaintCallbackInfo {
12137                                    viewport: rect,
12138                                    clip_rect,
12139                                    pixels_per_point: self.pixels_per_point,
12140                                    screen_size_px: [tw, th],
12141                                };
12142                                let descriptor = ScreenDescriptor {
12143                                    size_in_pixels: [tw, th],
12144                                    pixels_per_point: self.pixels_per_point,
12145                                    target_format: if is_layer {
12146                                        self.layer_target_format()
12147                                    } else if working_target {
12148                                        wgpu::TextureFormat::Rgba16Float
12149                                    } else {
12150                                        self.output_format
12151                                    },
12152                                    sample_count: if is_layer {
12153                                        1
12154                                    } else {
12155                                        self.active_surface_msaa_samples()
12156                                    },
12157                                };
12158                                let scope =
12159                                    callback_scope_key(cb, callback_id, pass.target, &descriptor);
12160                                let resources = self
12161                                    .callback_scoped_resources
12162                                    .get(&scope)
12163                                    .unwrap_or(&self.callback_resources);
12164                                // A panicking callback must not skip the restore below:
12165                                // the viewport/scissor would stay clamped to
12166                                // the callback rect and every later draw in the
12167                                // frame would be clipped away. Requires
12168                                // unwinding panics; under `panic = "abort"`
12169                                // nothing runs the restore.
12170                                let outcome = {
12171                                    let mut callback_pass = CallbackRenderPass::new(
12172                                        &mut rpass,
12173                                        descriptor.target_format,
12174                                        descriptor.sample_count,
12175                                    );
12176                                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
12177                                        cb.0.paint(info, &mut callback_pass, resources)
12178                                    }))
12179                                };
12180                                if outcome.is_err() {
12181                                    log::warn!("paint callback panicked; state restored");
12182                                }
12183                                rpass.set_viewport(0.0, 0.0, tw as f32, th as f32, 0.0, 1.0);
12184                                let restore = if restore_scissor.2 > 0 && restore_scissor.3 > 0 {
12185                                    restore_scissor
12186                                } else {
12187                                    (0, 0, 1, 1)
12188                                };
12189                                rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
12190                                rpass.set_bind_group(0, &self.globals_bind, &[]);
12191                                rpass.set_stencil_reference(STENCIL_BASE + clip_depth);
12192                            }
12193                        } else {
12194                            log::warn!("Unknown paint callback payload");
12195                        }
12196                    }
12197                }
12198            }
12199            if is_layer {
12200                clip_depth = clip_depth_stack.pop().unwrap_or(0);
12201            }
12202        }
12203
12204        let transient_ids: HashSet<u32> = flatten_ids_used.iter().copied().collect();
12205        let producer_ids = std::mem::take(&mut self.producer_layer_ids);
12206        // A blur scratch stays bound while its source layer is live, so a stable
12207        // layer reuses its allocation instead of reallocating each frame. Once
12208        // the source is swept the scratch id is forgotten and goes with it.
12209        self.blur_scratch_ids
12210            .retain(|&(source, _), _| producer_ids.contains(&source));
12211        let live_scratch: HashSet<u32> = self.blur_scratch_ids.values().copied().collect();
12212        let stale_layers: Vec<u32> = self
12213            .layer_pool
12214            .keys()
12215            .filter_map(|id| {
12216                (!producer_ids.contains(id)
12217                    && !transient_ids.contains(id)
12218                    && !live_scratch.contains(id))
12219                .then_some(*id)
12220            })
12221            .collect();
12222        for id in stale_layers {
12223            if let Some(layer) = self.layer_pool.remove(&id) {
12224                self.layer_bytes_total = self.layer_bytes_total.saturating_sub(layer.bytes);
12225                if layer.transient {
12226                    self.transient_layer_bytes_total =
12227                        self.transient_layer_bytes_total.saturating_sub(layer.bytes);
12228                }
12229            }
12230        }
12231        self.flatten_layer_ids = flatten_ids_used;
12232
12233        // Display pass: linear working space -> sRGB OETF -> swapchain
12234        if self.working_space
12235            && let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
12236                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
12237        {
12238            let swap_view = target_view.clone();
12239            let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
12240                label: Some("display transform"),
12241                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
12242                    view: &swap_view,
12243                    resolve_target: None,
12244                    ops: wgpu::Operations {
12245                        load: wgpu::LoadOp::Load,
12246                        store: wgpu::StoreOp::Store,
12247                    },
12248                    depth_slice: None,
12249                })],
12250                depth_stencil_attachment: None,
12251                timestamp_writes: None,
12252                occlusion_query_set: None,
12253                multiview_mask: None,
12254            });
12255            display_pass.set_pipeline(display_pipeline);
12256            display_pass.set_bind_group(1, ws_bind, &[]);
12257            display_pass.draw(0..3, 0..1);
12258        }
12259        self.recycle_blend_snapshots();
12260    }
12261
12262    /// Render a scene into an externally-provided texture view.
12263    /// Use this when embedding Repose in a host that owns the GPU.
12264    /// The host is responsible for submitting the encoder and handling present.
12265    pub fn render_to_view(
12266        &mut self,
12267        scene: &Scene,
12268        encoder: &mut wgpu::CommandEncoder,
12269        target_view: &wgpu::TextureView,
12270        width: u32,
12271        height: u32,
12272        clear_color: Option<[f64; 4]>,
12273    ) {
12274        self.resize(width, height);
12275
12276        if let Err(error) = self.begin_frame() {
12277            log::error!("render_to_view: {error:#}");
12278            return;
12279        }
12280
12281        if self.output_width == 0 || self.output_height == 0 {
12282            self.end_frame();
12283            return;
12284        }
12285
12286        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
12287        self.end_frame();
12288    }
12289}
12290
12291#[cfg(target_os = "linux")]
12292fn validate_dmabuf_plane(
12293    fd: &std::os::unix::io::OwnedFd,
12294    offset: u64,
12295    stride: u32,
12296    width: u32,
12297    height: u32,
12298    bytes_per_pixel: u32,
12299) -> anyhow::Result<u64> {
12300    if width == 0 || height == 0 || bytes_per_pixel == 0 {
12301        anyhow::bail!("DMA-BUF plane dimensions and bytes per pixel must be non-zero");
12302    }
12303    if !offset.is_multiple_of(4) || !stride.is_multiple_of(4) {
12304        anyhow::bail!("DMA-BUF offset and stride must be 4-byte aligned");
12305    }
12306    let row_bytes = (width as u64)
12307        .checked_mul(bytes_per_pixel as u64)
12308        .ok_or_else(|| anyhow::anyhow!("DMA-BUF row size overflow"))?;
12309    if (stride as u64) < row_bytes {
12310        anyhow::bail!(
12311            "DMA-BUF stride {} is smaller than row size {row_bytes}",
12312            stride
12313        );
12314    }
12315    let span = (height as u64 - 1)
12316        .checked_mul(stride as u64)
12317        .and_then(|value| value.checked_add(row_bytes))
12318        .ok_or_else(|| anyhow::anyhow!("DMA-BUF plane size overflow"))?;
12319    let file = std::fs::File::from(
12320        fd.try_clone()
12321            .map_err(|error| anyhow::anyhow!("duplicate DMA-BUF fd: {error}"))?,
12322    );
12323    let file_size = file
12324        .metadata()
12325        .map_err(|error| anyhow::anyhow!("stat DMA-BUF fd: {error}"))?
12326        .len();
12327    if offset > file_size || span > file_size - offset {
12328        anyhow::bail!("DMA-BUF plane offset/size {offset}+{span} exceeds file size {file_size}");
12329    }
12330    Ok(file_size)
12331}
12332
12333fn is_filterable_color_format(format: wgpu::TextureFormat) -> bool {
12334    matches!(
12335        format,
12336        wgpu::TextureFormat::Rgba8Unorm
12337            | wgpu::TextureFormat::Rgba8UnormSrgb
12338            | wgpu::TextureFormat::Bgra8Unorm
12339            | wgpu::TextureFormat::Bgra8UnormSrgb
12340            | wgpu::TextureFormat::Rgba16Unorm
12341            | wgpu::TextureFormat::Rgba16Float
12342            | wgpu::TextureFormat::Rgba32Float
12343            | wgpu::TextureFormat::Rgb10a2Unorm
12344    )
12345}
12346
12347fn native_format_supported(format: wgpu::TextureFormat, features: wgpu::Features) -> bool {
12348    if !is_filterable_color_format(format) {
12349        return false;
12350    }
12351    match format {
12352        wgpu::TextureFormat::Rgba16Unorm => {
12353            features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM)
12354        }
12355        wgpu::TextureFormat::Rgba32Float => features.contains(wgpu::Features::FLOAT32_FILTERABLE),
12356        _ => true,
12357    }
12358}
12359
12360fn validate_sampler_descriptor(
12361    device: &wgpu::Device,
12362    sampler: &wgpu::SamplerDescriptor<'_>,
12363) -> anyhow::Result<()> {
12364    if sampler.compare.is_some()
12365        || !matches!(
12366            sampler.mag_filter,
12367            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12368        )
12369        || !matches!(
12370            sampler.min_filter,
12371            wgpu::FilterMode::Nearest | wgpu::FilterMode::Linear
12372        )
12373        || sampler.anisotropy_clamp == 0
12374        || !sampler.lod_min_clamp.is_finite()
12375        || !sampler.lod_max_clamp.is_finite()
12376        || sampler.lod_min_clamp < 0.0
12377        || sampler.lod_max_clamp < sampler.lod_min_clamp
12378    {
12379        anyhow::bail!("native sampler must be non-comparison linear filtering with finite LODs");
12380    }
12381    if (matches!(sampler.address_mode_u, wgpu::AddressMode::ClampToBorder)
12382        || matches!(sampler.address_mode_v, wgpu::AddressMode::ClampToBorder)
12383        || matches!(sampler.address_mode_w, wgpu::AddressMode::ClampToBorder))
12384        && !device
12385            .features()
12386            .contains(wgpu::Features::ADDRESS_MODE_CLAMP_TO_BORDER)
12387    {
12388        anyhow::bail!("native sampler uses unsupported clamp-to-border addressing");
12389    }
12390    Ok(())
12391}
12392
12393fn validate_texture_dimensions(
12394    device: &wgpu::Device,
12395    width: u32,
12396    height: u32,
12397) -> anyhow::Result<()> {
12398    if width == 0
12399        || height == 0
12400        || width > device.limits().max_texture_dimension_2d
12401        || height > device.limits().max_texture_dimension_2d
12402    {
12403        anyhow::bail!("texture dimensions are outside the device limit");
12404    }
12405    Ok(())
12406}
12407
12408fn validate_image_handle(handle: u64) -> anyhow::Result<()> {
12409    if handle == 0 {
12410        anyhow::bail!("image handle 0 is reserved");
12411    }
12412    Ok(())
12413}
12414
12415/// Decoder limits for encoded uploads. `image::Limits::default()` only caps
12416/// allocations at 512 MiB, which a few hundred KB of hostile PNG can reach;
12417/// the dimension cap plus a decode-sized allocation cap keep the transient
12418/// inside the same envelope as the retained image budget.
12419fn decode_limits(max_dimension: u32) -> image::Limits {
12420    let mut limits = image::Limits::default();
12421    limits.max_image_width = Some(max_dimension);
12422    limits.max_image_height = Some(max_dimension);
12423    limits.max_alloc = Some(MAX_IMAGE_DECODE_BYTES);
12424    limits
12425}
12426
12427/// Decode an encoded image to RGBA8, rejecting oversized input *before* the
12428/// decoder allocates. The dimension probe runs against a separate reader
12429/// because `into_dimensions` consumes it.
12430fn decode_image_rgba8(data: &[u8], max_dimension: u32) -> anyhow::Result<(u32, u32, Vec<u8>)> {
12431    let format = image::guess_format(data)?;
12432    let (w, h) = {
12433        let mut probe = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12434        probe.limits(decode_limits(max_dimension));
12435        probe.into_dimensions()?
12436    };
12437    validate_image_decode_budget(w, h, max_dimension)?;
12438    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
12439    reader.limits(decode_limits(max_dimension));
12440    let rgba = reader.decode()?.into_rgba8();
12441    Ok((w, h, rgba.into_raw()))
12442}
12443
12444fn validate_image_decode_budget(w: u32, h: u32, max_dimension: u32) -> anyhow::Result<()> {
12445    if w == 0 || h == 0 {
12446        anyhow::bail!("encoded image has zero dimensions");
12447    }
12448    if w > max_dimension || h > max_dimension {
12449        anyhow::bail!("encoded image {w}x{h} exceeds the device texture limit {max_dimension}");
12450    }
12451    let bytes = checked_image_bytes(w, h, 4)?;
12452    if bytes > MAX_IMAGE_DECODE_BYTES {
12453        anyhow::bail!(
12454            "encoded image {w}x{h} needs {bytes} bytes to decode, over the {MAX_IMAGE_DECODE_BYTES} byte limit"
12455        );
12456    }
12457    Ok(())
12458}
12459
12460fn checked_image_bytes(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<u64> {
12461    if w == 0 || h == 0 || bytes_per_pixel == 0 {
12462        anyhow::bail!("image dimensions and bytes per pixel must be non-zero");
12463    }
12464    (w as u64)
12465        .checked_mul(h as u64)
12466        .and_then(|size| size.checked_mul(bytes_per_pixel as u64))
12467        .ok_or_else(|| anyhow::anyhow!("image byte size overflow"))
12468}
12469
12470fn texture_storage_bytes(
12471    format: wgpu::TextureFormat,
12472    width: u32,
12473    height: u32,
12474) -> anyhow::Result<u64> {
12475    if width == 0 || height == 0 {
12476        anyhow::bail!("texture dimensions must be non-zero");
12477    }
12478    let bytes = format.theoretical_memory_footprint(wgpu::Extent3d {
12479        width,
12480        height,
12481        depth_or_array_layers: 1,
12482    });
12483    if bytes == 0 {
12484        anyhow::bail!("texture format has no storage size");
12485    }
12486    Ok(bytes)
12487}
12488
12489fn texture_storage_bytes_with_samples(
12490    format: wgpu::TextureFormat,
12491    width: u32,
12492    height: u32,
12493    sample_count: u32,
12494) -> anyhow::Result<u64> {
12495    texture_storage_bytes(format, width, height)?
12496        .checked_mul(u64::from(sample_count.max(1)))
12497        .ok_or_else(|| anyhow::anyhow!("multisample texture size overflow"))
12498}
12499
12500fn checked_image_bytes_usize(w: u32, h: u32, bytes_per_pixel: u32) -> anyhow::Result<usize> {
12501    usize::try_from(checked_image_bytes(w, h, bytes_per_pixel)?)
12502        .map_err(|_| anyhow::anyhow!("image byte size exceeds addressable memory"))
12503}
12504
12505fn clamp_scissor(x: u32, y: u32, w: u32, h: u32, tw: u32, th: u32) -> (u32, u32, u32, u32) {
12506    if w == 0 || h == 0 || tw == 0 || th == 0 {
12507        return (0, 0, 0, 0);
12508    }
12509    let x = x.min(tw - 1);
12510    let y = y.min(th - 1);
12511    let w = w.min(tw - x);
12512    let h = h.min(th - y);
12513    (x, y, w, h)
12514}
12515
12516fn rect_to_scissor(r: repose_core::Rect, width: u32, height: u32) -> (u32, u32, u32, u32) {
12517    if width == 0
12518        || height == 0
12519        || !r.x.is_finite()
12520        || !r.y.is_finite()
12521        || !r.w.is_finite()
12522        || !r.h.is_finite()
12523        || r.w <= 0.0
12524        || r.h <= 0.0
12525    {
12526        return (0, 0, 0, 0);
12527    }
12528    // Inward, for the same reason as `to_scissor`: a clip covers only whole
12529    // pixels inside its rect.
12530    let x0 = r.x.ceil().max(0.0).min(width as f32);
12531    let y0 = r.y.ceil().max(0.0).min(height as f32);
12532    let x1 = (r.x + r.w).floor().max(x0).min(width as f32);
12533    let y1 = (r.y + r.h).floor().max(y0).min(height as f32);
12534    if x1 <= x0 || y1 <= y0 {
12535        return (0, 0, 0, 0);
12536    }
12537    (x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
12538}
12539
12540fn rect_to_ndc(r: repose_core::Rect, width: f32, height: f32) -> [f32; 4] {
12541    if width <= 0.0 || height <= 0.0 || !r.x.is_finite() || !r.y.is_finite() {
12542        return [0.0, 0.0, 0.0, 0.0];
12543    }
12544    let x0 = r.x / width * 2.0 - 1.0;
12545    let y0 = 1.0 - r.y / height * 2.0;
12546    let x1 = (r.x + r.w) / width * 2.0 - 1.0;
12547    let y1 = 1.0 - (r.y + r.h) / height * 2.0;
12548    [x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()]
12549}
12550
12551fn parent_rect_to_layer_rect(
12552    rect: repose_core::Rect,
12553    parent_transform: &Transform,
12554    origin: (f32, f32),
12555) -> Option<repose_core::Rect> {
12556    let inverse = parent_transform.inverse_linear()?;
12557    let tx = parent_transform.translate_x;
12558    let ty = parent_transform.translate_y;
12559    let corners = [
12560        (rect.x, rect.y),
12561        (rect.x + rect.w, rect.y),
12562        (rect.x, rect.y + rect.h),
12563        (rect.x + rect.w, rect.y + rect.h),
12564    ];
12565    let mut min_x = f32::INFINITY;
12566    let mut min_y = f32::INFINITY;
12567    let mut max_x = f32::NEG_INFINITY;
12568    let mut max_y = f32::NEG_INFINITY;
12569    for (x, y) in corners {
12570        let local_x = x - tx;
12571        let local_y = y - ty;
12572        let transformed_x = inverse[0] * local_x + inverse[1] * local_y;
12573        let transformed_y = inverse[2] * local_x + inverse[3] * local_y;
12574        min_x = min_x.min(transformed_x);
12575        min_y = min_y.min(transformed_y);
12576        max_x = max_x.max(transformed_x);
12577        max_y = max_y.max(transformed_y);
12578    }
12579    let result = repose_core::Rect {
12580        x: min_x - origin.0,
12581        y: min_y - origin.1,
12582        w: (max_x - min_x).max(0.0),
12583        h: (max_y - min_y).max(0.0),
12584    };
12585    (result.x.is_finite() && result.y.is_finite() && result.w.is_finite() && result.h.is_finite())
12586        .then_some(result)
12587}
12588
12589fn parent_radii_to_layer_radii(radii: [f32; 4], parent_transform: &Transform) -> Option<[f32; 4]> {
12590    let m = parent_transform.linear();
12591    let sx = (m[0] * m[0] + m[2] * m[2]).sqrt();
12592    let sy = (m[1] * m[1] + m[3] * m[3]).sqrt();
12593    (sx.is_finite() && sy.is_finite() && sx > 1e-6 && sy > 1e-6)
12594        .then(|| [radii[0] / sx, radii[1] / sy, radii[2] / sx, radii[3] / sy])
12595}
12596
12597fn parent_affine_to_layer_affine(
12598    affine: [f32; 6],
12599    parent_transform: &Transform,
12600    origin: (f32, f32),
12601) -> Option<[f32; 6]> {
12602    let inverse = parent_transform.inverse_linear()?;
12603    let tx = affine[4] - parent_transform.translate_x;
12604    let ty = affine[5] - parent_transform.translate_y;
12605    Some([
12606        inverse[0] * affine[0] + inverse[1] * affine[2],
12607        inverse[0] * affine[1] + inverse[1] * affine[3],
12608        inverse[2] * affine[0] + inverse[3] * affine[2],
12609        inverse[2] * affine[1] + inverse[3] * affine[3],
12610        inverse[0] * tx + inverse[1] * ty - origin.0,
12611        inverse[2] * tx + inverse[3] * ty - origin.1,
12612    ])
12613}
12614
12615fn checked_copy_region(
12616    region: repose_core::Rect,
12617    target_width: u32,
12618    target_height: u32,
12619) -> Option<(u32, u32, u32, u32)> {
12620    if target_width == 0
12621        || target_height == 0
12622        || !region.x.is_finite()
12623        || !region.y.is_finite()
12624        || !region.w.is_finite()
12625        || !region.h.is_finite()
12626        || region.w <= 0.0
12627        || region.h <= 0.0
12628    {
12629        return None;
12630    }
12631    let x0 = region.x.floor().max(0.0).min(target_width as f32) as u32;
12632    let y0 = region.y.floor().max(0.0).min(target_height as f32) as u32;
12633    let x1 = (region.x + region.w)
12634        .ceil()
12635        .max(x0 as f32)
12636        .min(target_width as f32) as u32;
12637    let y1 = (region.y + region.h)
12638        .ceil()
12639        .max(y0 as f32)
12640        .min(target_height as f32) as u32;
12641    (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
12642}
12643
12644fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
12645    let x0 = a.x.max(b.x);
12646    let y0 = a.y.max(b.y);
12647    let x1 = (a.x + a.w).min(b.x + b.w);
12648    let y1 = (a.y + a.h).min(b.y + b.h);
12649    repose_core::Rect {
12650        x: x0,
12651        y: y0,
12652        w: (x1 - x0).max(0.0),
12653        h: (y1 - y0).max(0.0),
12654    }
12655}
12656
12657#[cfg(test)]
12658mod tests {
12659    use super::*;
12660
12661    #[test]
12662    fn image_source_rect_uses_texel_edges() {
12663        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 3, 2)), 10, 8)
12664            .expect("source should be valid");
12665        assert_eq!(source.uv_min, [0.2, 0.125]);
12666        assert_eq!(source.uv_max, [0.5, 0.375]);
12667        assert_eq!(source.width, 3.0);
12668        assert_eq!(source.height, 2.0);
12669        assert!(resolve_image_source(Some(ImageSourceRect::new(8, 0, 3, 1)), 10, 8).is_none());
12670        assert!(resolve_image_source(Some(ImageSourceRect::new(0, 0, 0, 1)), 10, 8).is_none());
12671    }
12672
12673    #[test]
12674    fn image_fit_stays_inside_selected_source() {
12675        let source = resolve_image_source(Some(ImageSourceRect::new(2, 1, 4, 2)), 10, 8)
12676            .expect("source should be valid");
12677        let rect = repose_core::Rect {
12678            x: 20.0,
12679            y: 30.0,
12680            w: 8.0,
12681            h: 8.0,
12682        };
12683        let (_, uv) = image_fit_geometry(rect, ImageFit::Cover, ImageAlignment::Center, &source)
12684            .expect("cover geometry should be valid");
12685        assert!(uv[0] >= source.uv_min[0] && uv[2] <= source.uv_max[0]);
12686        assert!(uv[1] >= source.uv_min[1] && uv[3] <= source.uv_max[1]);
12687    }
12688
12689    #[test]
12690    fn ring_alignment_rejects_bad_input() {
12691        assert_eq!(align_up(0, 4).unwrap(), 0);
12692        assert_eq!(align_up(1, 4).unwrap(), 4);
12693        assert!(align_up(1, 3).is_err());
12694        assert!(align_up(u64::MAX, 4).is_err());
12695    }
12696
12697    /// Every stride must be a legal dynamic offset and a legal `align_up`
12698    /// alignment at every alignment a driver may report.
12699    #[test]
12700    fn mesh_uniform_stride_is_always_a_power_of_two() {
12701        for base in [4u64, 16, 32, 64, 128, 256, 512] {
12702            for size in [
12703                std::mem::size_of::<MeshUniform>() as u64,
12704                96,
12705                272,
12706                288,
12707                1024,
12708            ] {
12709                let stride = mesh_uniform_stride(size, base);
12710                assert!(
12711                    stride.is_power_of_two(),
12712                    "stride {stride} for size {size} base {base} is not a power of two"
12713                );
12714                assert_eq!(stride % base, 0, "stride {stride} misaligned to {base}");
12715                assert!(stride >= size, "stride {stride} below size {size}");
12716                assert!(
12717                    align_up(size, stride).is_ok(),
12718                    "capacity alignment rejects stride {stride}"
12719                );
12720            }
12721        }
12722    }
12723
12724    /// Byte layout of one `MeshUniform`, as the shader sees it.
12725    fn wgsl_type_size(module: &naga::Module, ty: naga::Handle<naga::Type>) -> u32 {
12726        match &module.types[ty].inner {
12727            naga::TypeInner::Scalar(_) | naga::TypeInner::Atomic(_) => 4,
12728            naga::TypeInner::Vector { size, .. } => 4 * *size as u32,
12729            naga::TypeInner::Array {
12730                size: naga::ArraySize::Constant(count),
12731                stride,
12732                ..
12733            } => stride * count.get(),
12734            _ => 0,
12735        }
12736    }
12737
12738    /// `shaders/mesh.wgsl` and `MeshUniform` are two halves of one contract:
12739    /// the shader reads the struct by byte offset, so any member added,
12740    /// dropped, resized or reordered on one side only shows up as scrambled
12741    /// gradient uniforms. Compare member offsets (and the total size) directly.
12742    #[test]
12743    fn mesh_uniform_matches_the_wgsl_member_layout() {
12744        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/mesh.wgsl");
12745        let module = naga::front::wgsl::parse_str(&std::fs::read_to_string(path).expect("read"))
12746            .expect("mesh.wgsl parses");
12747        let wgsl = module
12748            .types
12749            .iter()
12750            .filter(|(_, ty)| ty.name.as_deref() == Some("MeshUniform"))
12751            .find_map(|(_, ty)| match &ty.inner {
12752                naga::TypeInner::Struct { members, .. } => Some(members.as_slice()),
12753                _ => None,
12754            })
12755            .expect("mesh.wgsl declares MeshUniform");
12756        let rust = [
12757            ("paint", std::mem::offset_of!(MeshUniform, paint)),
12758            ("color0", std::mem::offset_of!(MeshUniform, color0)),
12759            ("color1", std::mem::offset_of!(MeshUniform, color1)),
12760            ("grad_start", std::mem::offset_of!(MeshUniform, grad_start)),
12761            ("grad_end", std::mem::offset_of!(MeshUniform, grad_end)),
12762            (
12763                "stop_colors",
12764                std::mem::offset_of!(MeshUniform, stop_colors),
12765            ),
12766            (
12767                "stop_offsets",
12768                std::mem::offset_of!(MeshUniform, stop_offsets),
12769            ),
12770        ];
12771        assert_eq!(wgsl.len(), rust.len(), "MeshUniform member count");
12772        for (member, (name, offset)) in wgsl.iter().zip(rust) {
12773            assert_eq!(member.name.as_deref(), Some(name), "member order");
12774            assert_eq!(member.offset as usize, offset, "offset of {name}");
12775        }
12776        let last = wgsl.last().expect("non-empty");
12777        let wgsl_size = last.offset as usize + wgsl_type_size(&module, last.ty) as usize;
12778        assert_eq!(wgsl_size, std::mem::size_of::<MeshUniform>());
12779        assert_eq!(wgsl_size, MESH_UNIFORM_BYTES);
12780    }
12781
12782    /// The stop table rides in the same uniform as the two-point ramps, so the
12783    /// packing invariants the shader relies on (count, clamped non-decreasing
12784    /// offsets, colours left in linear space) are checked where they are built.
12785    #[test]
12786    fn gradient_stops_pack_clamped_and_monotonic() {
12787        let stops = GradientStops::from_slice(&[
12788            repose_core::GradientStop {
12789                offset: 0.25,
12790                color: repose_core::Color::from_rgba(255, 0, 0, 255),
12791            },
12792            repose_core::GradientStop {
12793                offset: f32::NAN,
12794                color: repose_core::Color::from_rgba(0, 255, 0, 255),
12795            },
12796            repose_core::GradientStop {
12797                offset: -2.0,
12798                color: repose_core::Color::from_rgba(0, 0, 255, 255),
12799            },
12800            repose_core::GradientStop {
12801                offset: 9.0,
12802                color: repose_core::Color::from_rgba(255, 255, 255, 255),
12803            },
12804        ]);
12805        let (colors, offsets, count) = pack_gradient_stops(&stops);
12806        assert_eq!(count, 4);
12807        assert_eq!(offsets, [[0.25, 0.25, 0.25, 1.0], [0.0, 0.0, 0.0, 0.0]]);
12808        assert_eq!(colors[0], [1.0, 0.0, 0.0, 1.0]);
12809        assert_eq!(colors[1], [0.0, 1.0, 0.0, 1.0]);
12810        assert_eq!(colors[2], [0.0, 0.0, 1.0, 1.0]);
12811        assert_eq!(colors[3], [1.0, 1.0, 1.0, 1.0]);
12812        assert_eq!(
12813            pack_gradient_stops(&GradientStops::default()).2,
12814            0,
12815            "an empty table paints nothing"
12816        );
12817    }
12818
12819    /// A repack moves every surviving glyph, so eviction must never touch one
12820    /// the in-flight frame already holds UVs for, and must never empty the map.
12821    /// Glyph 1 is the coldest overall but in flight, so it has to survive even
12822    /// though glyphs 3 and 4 are warmer and evictable.
12823    #[test]
12824    fn atlas_eviction_spares_the_frame_in_flight() {
12825        fn key(glyph_id: u32) -> repose_text::CacheKey {
12826            repose_text::CacheKey {
12827                font_id: 1,
12828                glyph_id,
12829                font_size_bits: 16.0f32.to_bits(),
12830                variation: None,
12831            }
12832        }
12833        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12834        for (glyph_id, last_touch, last_used_frame) in
12835            [(1u32, 10u64, 9u64), (2, 20, 7), (3, 30, 7), (4, 40, 7)]
12836        {
12837            map.insert(
12838                key(glyph_id),
12839                GlyphInfo {
12840                    u0: 0.0,
12841                    v0: 0.0,
12842                    u1: 0.1,
12843                    v1: 0.1,
12844                    w: 10.0,
12845                    h: 10.0,
12846                    last_touch,
12847                    last_used_frame,
12848                },
12849            );
12850        }
12851        let mut used_area = 400;
12852        assert!(evict_atlas_lru(&mut map, &mut used_area, 9, 250));
12853        assert_eq!(used_area, 200);
12854        assert!(map.contains_key(&key(1)), "in-flight glyph was evicted");
12855        assert!(!map.contains_key(&key(2)) && !map.contains_key(&key(3)));
12856        assert!(map.contains_key(&key(4)));
12857    }
12858
12859    #[test]
12860    fn atlas_eviction_refuses_to_empty_the_atlas() {
12861        let only = repose_text::CacheKey {
12862            font_id: 1,
12863            glyph_id: 1,
12864            font_size_bits: 16.0f32.to_bits(),
12865            variation: None,
12866        };
12867        let mut map: HashMap<repose_text::CacheKey, GlyphInfo> = HashMap::new();
12868        map.insert(
12869            only,
12870            GlyphInfo {
12871                u0: 0.0,
12872                v0: 0.0,
12873                u1: 0.1,
12874                v1: 0.1,
12875                w: 10.0,
12876                h: 10.0,
12877                last_touch: 1,
12878                last_used_frame: 1,
12879            },
12880        );
12881        let mut used_area = 100;
12882        assert!(!evict_atlas_lru(&mut map, &mut used_area, 2, 0));
12883        assert_eq!(map.len(), 1);
12884        assert_eq!(used_area, 100);
12885    }
12886
12887    #[test]
12888    fn empty_and_negative_rects_are_suppressed() {
12889        assert_eq!(
12890            rect_to_scissor(
12891                repose_core::Rect {
12892                    x: 0.0,
12893                    y: 0.0,
12894                    w: 0.0,
12895                    h: 4.0,
12896                },
12897                16,
12898                16,
12899            ),
12900            (0, 0, 0, 0)
12901        );
12902        assert_eq!(
12903            rect_to_scissor(
12904                repose_core::Rect {
12905                    x: 0.0,
12906                    y: 0.0,
12907                    w: -1.0,
12908                    h: 4.0,
12909                },
12910                16,
12911                16,
12912            ),
12913            (0, 0, 0, 0)
12914        );
12915    }
12916
12917    #[test]
12918    fn copy_region_is_checked() {
12919        assert_eq!(
12920            checked_copy_region(
12921                repose_core::Rect {
12922                    x: 2.25,
12923                    y: 3.5,
12924                    w: 4.0,
12925                    h: 5.0,
12926                },
12927                16,
12928                16,
12929            ),
12930            Some((2, 3, 5, 6))
12931        );
12932        assert_eq!(
12933            checked_copy_region(
12934                repose_core::Rect {
12935                    x: 20.0,
12936                    y: 0.0,
12937                    w: 2.0,
12938                    h: 2.0,
12939                },
12940                16,
12941                16,
12942            ),
12943            None
12944        );
12945    }
12946
12947    #[test]
12948    fn decode_budget_rejects_oversized_encoded_images() {
12949        assert!(validate_image_decode_budget(64, 64, 8192).is_ok());
12950        assert!(validate_image_decode_budget(0, 64, 8192).is_err());
12951        assert!(validate_image_decode_budget(64, 0, 8192).is_err());
12952        assert!(validate_image_decode_budget(8193, 1, 8192).is_err());
12953        assert!(validate_image_decode_budget(1, 8193, 8192).is_err());
12954        // Fits the device, but not the decode allocation budget.
12955        assert!(validate_image_decode_budget(8192, 4096, 8192).is_err());
12956    }
12957
12958    #[test]
12959    fn only_wgpu_supported_sample_counts_are_accepted() {
12960        for samples in [1, 2, 4, 8, 16] {
12961            assert!(validate_sample_count("msaa_samples", samples).is_ok());
12962        }
12963        for samples in [0, 3, 5, 7, 9, 17, 32, u32::MAX] {
12964            assert!(
12965                validate_sample_count("msaa_samples", samples).is_err(),
12966                "{samples} must be rejected"
12967            );
12968        }
12969    }
12970
12971    #[test]
12972    fn p010_uses_ten_bit_sample_range() {
12973        let raw = make_yuv_transform_raw(ColorInfo::default(), PixelFormat::P010);
12974        let expected = 65535.0 / (64.0 * 1020.0);
12975        assert!((raw.b[3] - expected).abs() < 1e-6);
12976        assert_eq!(canonical_fourcc(PixelFormat::P010), P010_FOURCC);
12977        assert_eq!(canonical_fourcc(PixelFormat::Nv12), NV12_FOURCC);
12978        assert_eq!(
12979            make_yuv_transform_raw(
12980                ColorInfo {
12981                    transfer: repose_core::color::Transfer::Pq,
12982                    ..ColorInfo::default()
12983                },
12984                PixelFormat::Nv12,
12985            )
12986            .transfer[0],
12987            3.0
12988        );
12989        assert_eq!(
12990            make_yuv_transform_raw(
12991                ColorInfo {
12992                    transfer: repose_core::color::Transfer::Hlg,
12993                    ..ColorInfo::default()
12994                },
12995                PixelFormat::Nv12,
12996            )
12997            .transfer[0],
12998            4.0
12999        );
13000    }
13001}