Skip to main content

repose_render_wgpu/
lib.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3#[cfg(feature = "winit-surface")]
4use std::sync::Arc;
5#[cfg(feature = "winit-surface")]
6use std::panic::{AssertUnwindSafe, catch_unwind};
7use std::ops::{Deref, DerefMut};
8
9use repose_core::color::{ChromaSiting, ColorInfo, PixelFormat};
10use repose_core::request_frame;
11use repose_core::{
12    Brush, FontStyle, GlyphRasterConfig, PresentModePref, RenderBackend, Scene, SceneNode,
13    StrokeCap, Transform,
14};
15use wgpu::Instance;
16
17mod slug;
18
19#[derive(Clone)]
20struct UploadRing {
21    buf: wgpu::Buffer,
22    cap: u64,
23    head: u64,
24}
25
26impl UploadRing {
27    fn new(device: &wgpu::Device, label: &str, cap: u64) -> Self {
28        let buf = device.create_buffer(&wgpu::BufferDescriptor {
29            label: Some(label),
30            size: cap,
31            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
32            mapped_at_creation: false,
33        });
34        Self { buf, cap, head: 0 }
35    }
36
37    fn reset(&mut self) {
38        self.head = 0;
39    }
40
41    fn grow_to_fit(&mut self, device: &wgpu::Device, needed: u64) {
42        let start = (self.head + 3) & !3;
43        if start + needed <= self.cap {
44            return;
45        }
46        let new_cap = (start + needed).next_power_of_two();
47        self.buf = device.create_buffer(&wgpu::BufferDescriptor {
48            label: Some("upload ring (grown)"),
49            size: new_cap,
50            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
51            mapped_at_creation: false,
52        });
53        self.cap = new_cap;
54    }
55
56    fn alloc_write(&mut self, queue: &wgpu::Queue, bytes: &[u8]) -> (u64, u64) {
57        let len = bytes.len() as u64;
58        let start = (self.head + 3) & !3; // align to 4
59        let end = start + len;
60        assert!(end <= self.cap, "ring overflow - call grow_to_fit first");
61        queue.write_buffer(&self.buf, start, bytes);
62        self.head = end;
63        (start, len)
64    }
65}
66
67struct InstancedPipe<I: bytemuck::Pod> {
68    ring: UploadRing,
69    _marker: std::marker::PhantomData<I>,
70}
71
72impl<I: bytemuck::Pod> InstancedPipe<I> {
73    fn new(ring: UploadRing) -> Self {
74        Self {
75            ring,
76            _marker: std::marker::PhantomData,
77        }
78    }
79
80    fn upload(
81        &mut self,
82        device: &wgpu::Device,
83        queue: &wgpu::Queue,
84        data: &[I],
85    ) -> Option<(u64, u32)> {
86        if data.is_empty() {
87            return None;
88        }
89        let bytes = bytemuck::cast_slice(data);
90        self.ring.grow_to_fit(device, bytes.len() as u64);
91        let (off, wrote) = self.ring.alloc_write(queue, bytes);
92        debug_assert_eq!(wrote as usize, bytes.len());
93        Some((off, data.len() as u32))
94    }
95
96    fn reset(&mut self) {
97        self.ring.reset();
98    }
99}
100
101#[repr(C)]
102#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
103struct Globals {
104    ndc_to_px: [f32; 2],
105    _pad: [f32; 2],
106}
107
108fn make_globals(target_w: f32, target_h: f32) -> Globals {
109    Globals {
110        ndc_to_px: [target_w * 0.5, target_h * 0.5],
111        _pad: [0.0, 0.0],
112    }
113}
114
115pub struct WgpuSceneRenderer {
116    pub device: wgpu::Device,
117    pub queue: wgpu::Queue,
118    pub output_format: wgpu::TextureFormat,
119    pub output_width: u32,
120    pub output_height: u32,
121
122    // Render pipelines. Two sets: one for the MSAA surface pass, one for
123    // graphics-layer render-to-texture passes (sample_count = 1).
124    surface_pipes: Pipelines,
125    layer_pipes: Pipelines,
126
127    // Instanced draw rings
128    rects: InstancedPipe<RectInstance>,
129    borders: InstancedPipe<BorderInstance>,
130    ellipses: InstancedPipe<EllipseInstance>,
131    ellipse_borders: InstancedPipe<EllipseBorderInstance>,
132    arcs: InstancedPipe<ArcInstance>,
133    glyph_mask: InstancedPipe<GlyphInstance>,
134    glyph_color: InstancedPipe<GlyphInstance>,
135
136    // Image bind layouts and shared sampler
137    image_bind_layout_rgba: wgpu::BindGroupLayout,
138    image_bind_layout_nv12: wgpu::BindGroupLayout,
139    image_sampler: wgpu::Sampler,
140    layer_sampler: wgpu::Sampler,
141    layer_sampler_linear: wgpu::Sampler,
142
143    // Blur composite ring (for graphics-layer drop shadows)
144    blur_ring: UploadRing,
145
146    text_bind_layout: wgpu::BindGroupLayout,
147
148    // Stencil clip ring
149    clip_ring: UploadRing,
150
151    // Tessellated vector glyph pipeline (always enabled)
152    slug_enabled: bool,
153    slug_ring: UploadRing,
154    slug_cache: slug::GlyphSlugCache,
155
156    // Instanced NV12 ring
157    nv12: InstancedPipe<Nv12Instance>,
158
159    msaa_samples: u32,
160
161    // Depth-stencil target
162    depth_stencil_tex: wgpu::Texture,
163    depth_stencil_view: wgpu::TextureView,
164
165    // Optional MSAA color target
166    msaa_tex: Option<wgpu::Texture>,
167    msaa_view: Option<wgpu::TextureView>,
168
169    globals_layout: wgpu::BindGroupLayout,
170    globals_buf: wgpu::Buffer,
171    globals_bind: wgpu::BindGroup,
172
173    // Glyph atlas
174    atlas_mask: AtlasA8,
175    atlas_color: AtlasRGBA,
176
177    // Image management
178    next_image_handle: u64,
179    images: HashMap<u64, ImageTex>,
180
181    // Eviction stats
182    frame_index: u64,
183    image_bytes_total: u64,
184    image_evict_after_frames: u64,
185    image_budget_bytes: u64,
186
187    // Graphics layer pool. Maps `SceneNode::BeginLayer::layer_id` to a
188    // cached offscreen render target.
189    layer_pool: HashMap<u32, LayerTarget>,
190
191    // Linear working-space mode (default off -> fast playback path).
192    // When enabled, the scene is rendered into an Rgba16Float intermediate
193    // texture, then a final full-screen pass applies the display OETF.
194    working_space: bool,
195    ws_tex: Option<wgpu::Texture>,
196    ws_view: Option<wgpu::TextureView>,
197    ws_bind: Option<wgpu::BindGroup>,
198    display_pipeline: Option<wgpu::RenderPipeline>,
199    display_layout: Option<wgpu::BindGroupLayout>,
200}
201
202pub struct WgpuSurfaceBackend {
203    pub surface: Option<wgpu::Surface<'static>>,
204    pub surface_config: Option<wgpu::SurfaceConfiguration>,
205    pub renderer: WgpuSceneRenderer,
206}
207
208impl std::ops::Deref for WgpuSurfaceBackend {
209    type Target = WgpuSceneRenderer;
210    fn deref(&self) -> &Self::Target { &self.renderer }
211}
212impl std::ops::DerefMut for WgpuSurfaceBackend {
213    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.renderer }
214}
215
216#[cfg(feature = "winit-surface")]
217pub type WgpuBackend = WgpuSurfaceBackend;
218
219impl Drop for WgpuSceneRenderer {
220    fn drop(&mut self) {
221        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
222    }
223}
224
225#[derive(Clone)]
226struct LayerTarget {
227    texture: wgpu::Texture,
228    view: wgpu::TextureView,
229    bind: wgpu::BindGroup,
230    bind_linear: wgpu::BindGroup,
231    depth_stencil_tex: wgpu::Texture,
232    depth_stencil_view: wgpu::TextureView,
233    width: u32,
234    height: u32,
235    rect_px: (f32, f32, f32, f32),
236}
237
238/// Identifies which render target a `Pass` draws into.
239#[derive(Clone, Copy)]
240enum PassTarget {
241    Surface,
242    Layer(u32),
243}
244
245/// A bundle of render pipelines for a single sample-count target. Created
246/// twice: once with `sample_count = msaa_samples` for the surface pass, and
247/// once with `sample_count = 1` for graphics-layer render-to-texture passes
248/// (where MSAA is wasted).
249struct Pipelines {
250    rects: wgpu::RenderPipeline,
251    borders: wgpu::RenderPipeline,
252    ellipses: wgpu::RenderPipeline,
253    ellipse_borders: wgpu::RenderPipeline,
254    arcs: wgpu::RenderPipeline,
255    text_mask: wgpu::RenderPipeline,
256    text_color: wgpu::RenderPipeline,
257    image_rgba: wgpu::RenderPipeline,
258    image_nv12: wgpu::RenderPipeline,
259    blur: wgpu::RenderPipeline,
260    blur_content: wgpu::RenderPipeline,
261    clip_a2c: wgpu::RenderPipeline,
262    clip_bin: wgpu::RenderPipeline,
263    clip_dec: wgpu::RenderPipeline,
264    slug: Option<wgpu::RenderPipeline>,
265}
266
267impl Pipelines {
268    fn create(
269        device: &wgpu::Device,
270        format: wgpu::TextureFormat,
271        sample_count: u32,
272        globals_layout: &wgpu::BindGroupLayout,
273        text_bind_layout: &wgpu::BindGroupLayout,
274        image_bind_layout_nv12: &wgpu::BindGroupLayout,
275        clip_pipeline_layout: &wgpu::PipelineLayout,
276        stencil_for_content: &wgpu::DepthStencilState,
277        stencil_for_clip_inc: &wgpu::DepthStencilState,
278        stencil_for_clip_dec: &wgpu::DepthStencilState,
279        clip_color_target: &wgpu::ColorTargetState,
280        clip_vertex_layout: &wgpu::VertexBufferLayout,
281    ) -> Self {
282        let msaa_state = wgpu::MultisampleState {
283            count: sample_count,
284            mask: !0,
285            alpha_to_coverage_enabled: false,
286        };
287
288        macro_rules! make_content_pipeline {
289            ($name:ident, $shader:literal, $inst_type:ty, $attrs:expr) => {
290                let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
291                    label: Some(concat!($shader, ".wgsl")),
292                    source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(concat!(
293                        "shaders/", $shader, ".wgsl"
294                    )))),
295                });
296                let pipeline_layout =
297                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
298                        label: Some(concat!($shader, " pipeline layout")),
299                        bind_group_layouts: &[Some(globals_layout)],
300                        immediate_size: 0,
301                    });
302                let $name = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
303                    label: Some(concat!($shader, " pipeline")),
304                    layout: Some(&pipeline_layout),
305                    vertex: wgpu::VertexState {
306                        module: &shader_module,
307                        entry_point: Some("vs_main"),
308                        buffers: &[Some(wgpu::VertexBufferLayout {
309                            array_stride: std::mem::size_of::<$inst_type>() as u64,
310                            step_mode: wgpu::VertexStepMode::Instance,
311                            attributes: $attrs,
312                        })],
313                        compilation_options: wgpu::PipelineCompilationOptions::default(),
314                    },
315                    fragment: Some(wgpu::FragmentState {
316                        module: &shader_module,
317                        entry_point: Some("fs_main"),
318                        targets: &[Some(wgpu::ColorTargetState {
319                            format,
320                            blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
321                            write_mask: wgpu::ColorWrites::ALL,
322                        })],
323                        compilation_options: wgpu::PipelineCompilationOptions::default(),
324                    }),
325                    primitive: wgpu::PrimitiveState::default(),
326                    depth_stencil: Some(stencil_for_content.clone()),
327                    multisample: msaa_state,
328                    multiview_mask: None,
329                    cache: None,
330                });
331            };
332        }
333
334        let rect_attrs: &[wgpu::VertexAttribute] = &[
335            wgpu::VertexAttribute {
336                shader_location: 0,
337                offset: 0,
338                format: wgpu::VertexFormat::Float32x4,
339            },
340            wgpu::VertexAttribute {
341                shader_location: 1,
342                offset: 16,
343                format: wgpu::VertexFormat::Float32x4,
344            },
345            wgpu::VertexAttribute {
346                shader_location: 2,
347                offset: 32,
348                format: wgpu::VertexFormat::Uint32,
349            },
350            wgpu::VertexAttribute {
351                shader_location: 3,
352                offset: 48,
353                format: wgpu::VertexFormat::Float32x4,
354            },
355            wgpu::VertexAttribute {
356                shader_location: 4,
357                offset: 64,
358                format: wgpu::VertexFormat::Float32x4,
359            },
360            wgpu::VertexAttribute {
361                shader_location: 5,
362                offset: 80,
363                format: wgpu::VertexFormat::Float32x2,
364            },
365            wgpu::VertexAttribute {
366                shader_location: 6,
367                offset: 88,
368                format: wgpu::VertexFormat::Float32x2,
369            },
370            wgpu::VertexAttribute {
371                shader_location: 7,
372                offset: 96,
373                format: wgpu::VertexFormat::Float32x2,
374            },
375        ];
376        let border_attrs: &[wgpu::VertexAttribute] = &[
377            wgpu::VertexAttribute {
378                shader_location: 0,
379                offset: 0,
380                format: wgpu::VertexFormat::Float32x4,
381            },
382            wgpu::VertexAttribute {
383                shader_location: 1,
384                offset: 16,
385                format: wgpu::VertexFormat::Float32x4,
386            },
387            wgpu::VertexAttribute {
388                shader_location: 2,
389                offset: 32,
390                format: wgpu::VertexFormat::Float32,
391            },
392            wgpu::VertexAttribute {
393                shader_location: 3,
394                offset: 36,
395                format: wgpu::VertexFormat::Float32x4,
396            },
397            wgpu::VertexAttribute {
398                shader_location: 4,
399                offset: 52,
400                format: wgpu::VertexFormat::Float32x2,
401            },
402        ];
403        let ellipse_attrs: &[wgpu::VertexAttribute] = &[
404            wgpu::VertexAttribute {
405                shader_location: 0,
406                offset: 0,
407                format: wgpu::VertexFormat::Float32x4,
408            },
409            wgpu::VertexAttribute {
410                shader_location: 1,
411                offset: 16,
412                format: wgpu::VertexFormat::Float32x4,
413            },
414            wgpu::VertexAttribute {
415                shader_location: 2,
416                offset: 32,
417                format: wgpu::VertexFormat::Float32x2,
418            },
419        ];
420        let ellipse_border_attrs: &[wgpu::VertexAttribute] = &[
421            wgpu::VertexAttribute {
422                shader_location: 0,
423                offset: 0,
424                format: wgpu::VertexFormat::Float32x4,
425            },
426            wgpu::VertexAttribute {
427                shader_location: 1,
428                offset: 16,
429                format: wgpu::VertexFormat::Float32,
430            },
431            wgpu::VertexAttribute {
432                shader_location: 2,
433                offset: 20,
434                format: wgpu::VertexFormat::Float32,
435            },
436            wgpu::VertexAttribute {
437                shader_location: 3,
438                offset: 24,
439                format: wgpu::VertexFormat::Float32x4,
440            },
441            wgpu::VertexAttribute {
442                shader_location: 4,
443                offset: 40,
444                format: wgpu::VertexFormat::Float32x2,
445            },
446        ];
447
448        make_content_pipeline!(rects, "rect", RectInstance, rect_attrs);
449        make_content_pipeline!(borders, "border", BorderInstance, border_attrs);
450        make_content_pipeline!(ellipses, "ellipse", EllipseInstance, ellipse_attrs);
451        make_content_pipeline!(
452            ellipse_borders,
453            "ellipse_border",
454            EllipseBorderInstance,
455            ellipse_border_attrs
456        );
457
458        let arc_attrs: &[wgpu::VertexAttribute] = &[
459            wgpu::VertexAttribute {
460                shader_location: 0,
461                offset: 0,
462                format: wgpu::VertexFormat::Float32x4,
463            },
464            wgpu::VertexAttribute {
465                shader_location: 1,
466                offset: 16,
467                format: wgpu::VertexFormat::Float32,
468            },
469            wgpu::VertexAttribute {
470                shader_location: 2,
471                offset: 20,
472                format: wgpu::VertexFormat::Float32,
473            },
474            wgpu::VertexAttribute {
475                shader_location: 3,
476                offset: 24,
477                format: wgpu::VertexFormat::Float32,
478            },
479            wgpu::VertexAttribute {
480                shader_location: 4,
481                offset: 28,
482                format: wgpu::VertexFormat::Float32,
483            },
484            wgpu::VertexAttribute {
485                shader_location: 5,
486                offset: 32,
487                format: wgpu::VertexFormat::Float32x4,
488            },
489            wgpu::VertexAttribute {
490                shader_location: 6,
491                offset: 48,
492                format: wgpu::VertexFormat::Float32x2,
493            },
494            wgpu::VertexAttribute {
495                shader_location: 7,
496                offset: 56,
497                format: wgpu::VertexFormat::Float32,
498            },
499        ];
500
501        make_content_pipeline!(arcs, "arc", ArcInstance, arc_attrs);
502
503        // Text (mask)
504        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
505            label: Some("text.wgsl"),
506            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
507        });
508        // Text (color)
509        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
510            label: Some("text_color.wgsl"),
511            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
512                "shaders/text_color.wgsl"
513            ))),
514        });
515        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
516            label: Some("text pipeline layout"),
517            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
518            immediate_size: 0,
519        });
520        let glyph_vertex = wgpu::VertexBufferLayout {
521            array_stride: std::mem::size_of::<GlyphInstance>() as u64,
522            step_mode: wgpu::VertexStepMode::Instance,
523            attributes: &[
524                wgpu::VertexAttribute {
525                    shader_location: 0,
526                    offset: 0,
527                    format: wgpu::VertexFormat::Float32x4,
528                },
529                wgpu::VertexAttribute {
530                    shader_location: 1,
531                    offset: 16,
532                    format: wgpu::VertexFormat::Float32x4,
533                },
534                wgpu::VertexAttribute {
535                    shader_location: 2,
536                    offset: 32,
537                    format: wgpu::VertexFormat::Float32x4,
538                },
539                wgpu::VertexAttribute {
540                    shader_location: 3,
541                    offset: 48,
542                    format: wgpu::VertexFormat::Float32x2,
543                },
544            ],
545        };
546        let text_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
547            label: Some("text pipeline (mask)"),
548            layout: Some(&text_pipeline_layout),
549            vertex: wgpu::VertexState {
550                module: &text_mask_shader,
551                entry_point: Some("vs_main"),
552                buffers: &[Some(glyph_vertex.clone())],
553                compilation_options: wgpu::PipelineCompilationOptions::default(),
554            },
555            fragment: Some(wgpu::FragmentState {
556                module: &text_mask_shader,
557                entry_point: Some("fs_main"),
558                targets: &[Some(wgpu::ColorTargetState {
559                    format,
560                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
561                    write_mask: wgpu::ColorWrites::ALL,
562                })],
563                compilation_options: wgpu::PipelineCompilationOptions::default(),
564            }),
565            primitive: wgpu::PrimitiveState::default(),
566            depth_stencil: Some(stencil_for_content.clone()),
567            multisample: msaa_state,
568            multiview_mask: None,
569            cache: None,
570        });
571        let text_color = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
572            label: Some("text pipeline (color)"),
573            layout: Some(&text_pipeline_layout),
574            vertex: wgpu::VertexState {
575                module: &text_color_shader,
576                entry_point: Some("vs_main"),
577                buffers: &[Some(glyph_vertex)],
578                compilation_options: wgpu::PipelineCompilationOptions::default(),
579            },
580            fragment: Some(wgpu::FragmentState {
581                module: &text_color_shader,
582                entry_point: Some("fs_main"),
583                targets: &[Some(wgpu::ColorTargetState {
584                    format,
585                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
586                    write_mask: wgpu::ColorWrites::ALL,
587                })],
588                compilation_options: wgpu::PipelineCompilationOptions::default(),
589            }),
590            primitive: wgpu::PrimitiveState::default(),
591            depth_stencil: Some(stencil_for_content.clone()),
592            multisample: msaa_state,
593            multiview_mask: None,
594            cache: None,
595        });
596        // image_rgba reuses the text color pipeline (same vertex/bindings).
597        let image_rgba = text_color.clone();
598
599        // Blur composite pipeline (graphics-layer drop shadow)
600        let blur_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
601            label: Some("blur_shadow.wgsl"),
602            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
603                "shaders/blur_shadow.wgsl"
604            ))),
605        });
606        let blur_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
607            label: Some("blur pipeline layout"),
608            bind_group_layouts: &[Some(globals_layout), Some(text_bind_layout)],
609            immediate_size: 0,
610        });
611        let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
612            label: Some("blur pipeline"),
613            layout: Some(&blur_pipeline_layout),
614            vertex: wgpu::VertexState {
615                module: &blur_shader,
616                entry_point: Some("vs_main"),
617                buffers: &[Some(wgpu::VertexBufferLayout {
618                    array_stride: std::mem::size_of::<BlurInstance>() as u64,
619                    step_mode: wgpu::VertexStepMode::Instance,
620                    attributes: &[
621                        wgpu::VertexAttribute {
622                            shader_location: 0,
623                            offset: 0,
624                            format: wgpu::VertexFormat::Float32x4,
625                        },
626                        wgpu::VertexAttribute {
627                            shader_location: 1,
628                            offset: 16,
629                            format: wgpu::VertexFormat::Float32x4,
630                        },
631                        wgpu::VertexAttribute {
632                            shader_location: 2,
633                            offset: 32,
634                            format: wgpu::VertexFormat::Float32x4,
635                        },
636                        wgpu::VertexAttribute {
637                            shader_location: 3,
638                            offset: 48,
639                            format: wgpu::VertexFormat::Float32x2,
640                        },
641                        wgpu::VertexAttribute {
642                            shader_location: 4,
643                            offset: 56,
644                            format: wgpu::VertexFormat::Float32x2,
645                        },
646                    ],
647                })],
648                compilation_options: wgpu::PipelineCompilationOptions::default(),
649            },
650            fragment: Some(wgpu::FragmentState {
651                module: &blur_shader,
652                entry_point: Some("fs_main"),
653                targets: &[Some(wgpu::ColorTargetState {
654                    format,
655                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
656                    write_mask: wgpu::ColorWrites::ALL,
657                })],
658                compilation_options: wgpu::PipelineCompilationOptions::default(),
659            }),
660            primitive: wgpu::PrimitiveState::default(),
661            depth_stencil: Some(stencil_for_content.clone()),
662            multisample: msaa_state,
663            multiview_mask: None,
664            cache: None,
665        });
666
667        // Content blur pipeline (full RGBA gaussian blur)
668        let blur_content_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
669            label: Some("blur_content.wgsl"),
670            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
671                "shaders/blur_content.wgsl"
672            ))),
673        });
674        let blur_content = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
675            label: Some("blur content pipeline"),
676            layout: Some(&blur_pipeline_layout),
677            vertex: wgpu::VertexState {
678                module: &blur_content_shader,
679                entry_point: Some("vs_main"),
680                buffers: &[Some(wgpu::VertexBufferLayout {
681                    array_stride: std::mem::size_of::<BlurInstance>() as u64,
682                    step_mode: wgpu::VertexStepMode::Instance,
683                    attributes: &[
684                        wgpu::VertexAttribute {
685                            shader_location: 0,
686                            offset: 0,
687                            format: wgpu::VertexFormat::Float32x4,
688                        },
689                        wgpu::VertexAttribute {
690                            shader_location: 1,
691                            offset: 16,
692                            format: wgpu::VertexFormat::Float32x4,
693                        },
694                        wgpu::VertexAttribute {
695                            shader_location: 2,
696                            offset: 32,
697                            format: wgpu::VertexFormat::Float32x4,
698                        },
699                        wgpu::VertexAttribute {
700                            shader_location: 3,
701                            offset: 48,
702                            format: wgpu::VertexFormat::Float32x2,
703                        },
704                        wgpu::VertexAttribute {
705                            shader_location: 4,
706                            offset: 56,
707                            format: wgpu::VertexFormat::Float32x2,
708                        },
709                    ],
710                })],
711                compilation_options: wgpu::PipelineCompilationOptions::default(),
712            },
713            fragment: Some(wgpu::FragmentState {
714                module: &blur_content_shader,
715                entry_point: Some("fs_main"),
716                targets: &[Some(wgpu::ColorTargetState {
717                    format,
718                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
719                    write_mask: wgpu::ColorWrites::ALL,
720                })],
721                compilation_options: wgpu::PipelineCompilationOptions::default(),
722            }),
723            primitive: wgpu::PrimitiveState::default(),
724            depth_stencil: Some(stencil_for_content.clone()),
725            multisample: msaa_state,
726            multiview_mask: None,
727            cache: None,
728        });
729
730        // NV12 Image Pipeline
731        let image_nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
732            label: Some("image_nv12.wgsl"),
733            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
734                "shaders/image_nv12.wgsl"
735            ))),
736        });
737        let image_nv12_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
738            label: Some("image nv12 pipeline layout"),
739            bind_group_layouts: &[Some(globals_layout), Some(image_bind_layout_nv12)],
740            immediate_size: 0,
741        });
742        let image_nv12 = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
743            label: Some("image nv12 pipeline"),
744            layout: Some(&image_nv12_layout),
745            vertex: wgpu::VertexState {
746                module: &image_nv12_shader,
747                entry_point: Some("vs_main"),
748                buffers: &[Some(wgpu::VertexBufferLayout {
749                    array_stride: std::mem::size_of::<Nv12Instance>() as u64,
750                    step_mode: wgpu::VertexStepMode::Instance,
751                    attributes: &[
752                        wgpu::VertexAttribute {
753                            shader_location: 0,
754                            offset: 0,
755                            format: wgpu::VertexFormat::Float32x4,
756                        },
757                        wgpu::VertexAttribute {
758                            shader_location: 1,
759                            offset: 16,
760                            format: wgpu::VertexFormat::Float32x4,
761                        },
762                        wgpu::VertexAttribute {
763                            shader_location: 2,
764                            offset: 32,
765                            format: wgpu::VertexFormat::Float32x4,
766                        },
767                        wgpu::VertexAttribute {
768                            shader_location: 3,
769                            offset: 48,
770                            format: wgpu::VertexFormat::Float32,
771                        },
772                        wgpu::VertexAttribute {
773                            shader_location: 4,
774                            offset: 52,
775                            format: wgpu::VertexFormat::Float32x2,
776                        },
777                    ],
778                })],
779                compilation_options: wgpu::PipelineCompilationOptions::default(),
780            },
781            fragment: Some(wgpu::FragmentState {
782                module: &image_nv12_shader,
783                entry_point: Some("fs_main"),
784                targets: &[Some(wgpu::ColorTargetState {
785                    format,
786                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
787                    write_mask: wgpu::ColorWrites::ALL,
788                })],
789                compilation_options: wgpu::PipelineCompilationOptions::default(),
790            }),
791            primitive: wgpu::PrimitiveState::default(),
792            depth_stencil: Some(stencil_for_content.clone()),
793            multisample: msaa_state,
794            multiview_mask: None,
795            cache: None,
796        });
797
798        // Clipping
799        let clip_shader_a2c = device.create_shader_module(wgpu::ShaderModuleDescriptor {
800            label: Some("clip_round_rect_a2c.wgsl"),
801            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
802                "shaders/clip_round_rect_a2c.wgsl"
803            ))),
804        });
805        let clip_shader_bin = device.create_shader_module(wgpu::ShaderModuleDescriptor {
806            label: Some("clip_round_rect_bin.wgsl"),
807            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
808                "shaders/clip_round_rect_bin.wgsl"
809            ))),
810        });
811        let clip_a2c = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
812            label: Some("clip pipeline (a2c)"),
813            layout: Some(clip_pipeline_layout),
814            vertex: wgpu::VertexState {
815                module: &clip_shader_a2c,
816                entry_point: Some("vs_main"),
817                buffers: &[Some(clip_vertex_layout.clone())],
818                compilation_options: wgpu::PipelineCompilationOptions::default(),
819            },
820            fragment: Some(wgpu::FragmentState {
821                module: &clip_shader_a2c,
822                entry_point: Some("fs_main"),
823                targets: &[Some(clip_color_target.clone())],
824                compilation_options: wgpu::PipelineCompilationOptions::default(),
825            }),
826            primitive: wgpu::PrimitiveState::default(),
827            depth_stencil: Some(stencil_for_clip_inc.clone()),
828            multisample: wgpu::MultisampleState {
829                count: sample_count,
830                mask: !0,
831                alpha_to_coverage_enabled: sample_count > 1,
832            },
833            multiview_mask: None,
834            cache: None,
835        });
836        let clip_bin = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
837            label: Some("clip pipeline (bin)"),
838            layout: Some(clip_pipeline_layout),
839            vertex: wgpu::VertexState {
840                module: &clip_shader_bin,
841                entry_point: Some("vs_main"),
842                buffers: &[Some(clip_vertex_layout.clone())],
843                compilation_options: wgpu::PipelineCompilationOptions::default(),
844            },
845            fragment: Some(wgpu::FragmentState {
846                module: &clip_shader_bin,
847                entry_point: Some("fs_main"),
848                targets: &[Some(clip_color_target.clone())],
849                compilation_options: wgpu::PipelineCompilationOptions::default(),
850            }),
851            primitive: wgpu::PrimitiveState::default(),
852            depth_stencil: Some(stencil_for_clip_inc.clone()),
853            multisample: wgpu::MultisampleState {
854                count: sample_count,
855                mask: !0,
856                alpha_to_coverage_enabled: false,
857            },
858            multiview_mask: None,
859            cache: None,
860        });
861        let clip_dec = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
862            label: Some("clip pipeline (dec)"),
863            layout: Some(clip_pipeline_layout),
864            vertex: wgpu::VertexState {
865                module: &clip_shader_bin,
866                entry_point: Some("vs_main"),
867                buffers: &[Some(clip_vertex_layout.clone())],
868                compilation_options: wgpu::PipelineCompilationOptions::default(),
869            },
870            fragment: Some(wgpu::FragmentState {
871                module: &clip_shader_bin,
872                entry_point: Some("fs_main"),
873                targets: &[Some(clip_color_target.clone())],
874                compilation_options: wgpu::PipelineCompilationOptions::default(),
875            }),
876            primitive: wgpu::PrimitiveState::default(),
877            depth_stencil: Some(stencil_for_clip_dec.clone()),
878            multisample: wgpu::MultisampleState {
879                count: sample_count,
880                mask: !0,
881                alpha_to_coverage_enabled: false,
882            },
883            multiview_mask: None,
884            cache: None,
885        });
886
887        let slug = Some(slug::create_pipeline(
888            device,
889            format,
890            sample_count,
891            stencil_for_content,
892        ));
893
894        Self {
895            rects,
896            borders,
897            ellipses,
898            ellipse_borders,
899            arcs,
900            text_mask,
901            text_color,
902            image_rgba,
903            image_nv12,
904            blur,
905            blur_content,
906            clip_a2c,
907            clip_bin,
908            clip_dec,
909            slug,
910        }
911    }
912}
913
914/// A segment of the frame that draws into a single render target.
915struct Pass {
916    target: PassTarget,
917    /// The initial scissor to apply to the rpass when it is opened.
918    initial_scissor: (u32, u32, u32, u32),
919    /// `None` means `LoadOp::Load` (resume existing content);
920    /// `Some(c)` means `LoadOp::Clear(c)`.
921    clear_color: Option<[f32; 4]>,
922    cmds: Vec<Cmd>,
923}
924
925#[allow(non_snake_case)]
926enum Cmd {
927    ClipPush {
928        off: u64,
929        cnt: u32,
930        scissor: (u32, u32, u32, u32),
931        difference: bool,
932        rounded: bool,
933    },
934    ClipPop {
935        scissor: (u32, u32, u32, u32),
936    },
937    Rect {
938        off: u64,
939        cnt: u32,
940    },
941    Border {
942        off: u64,
943        cnt: u32,
944    },
945    Ellipse {
946        off: u64,
947        cnt: u32,
948    },
949    EllipseBorder {
950        off: u64,
951        cnt: u32,
952    },
953    Arc {
954        off: u64,
955        cnt: u32,
956    },
957    GlyphsMask {
958        off: u64,
959        cnt: u32,
960    },
961    GlyphsColor {
962        off: u64,
963        cnt: u32,
964    },
965    GlyphsVector {
966        off: u64,
967        cnt: u32,
968    },
969    ImageRgba {
970        off: u64,
971        cnt: u32,
972        handle: u64,
973    },
974    ImageNv12 {
975        off: u64,
976        cnt: u32,
977        handle: u64,
978    },
979    PushTransform(Transform),
980    PopTransform,
981    /// Composite a previously-rendered graphics layer back into the
982    /// current target as a textured quad. The quad's vertex buffer
983    /// lives in `self.glyph_color.ring` (a `GlyphInstance`).
984    CompositeLayer {
985        off: u64,
986        cnt: u32,
987        layer_id: u32,
988        alpha: f32,
989    },
990    /// Composite a blurred drop shadow of a previously-rendered graphics
991    /// layer. The quad's vertex buffer lives in `self.blur_ring` (a
992    /// `BlurInstance`).
993    CompositeShadow {
994        off: u64,
995        cnt: u32,
996        layer_id: u32,
997    },
998    /// Apply gaussian blur to a layer and composite the blurred result.
999    /// Uses the `blur_content` pipeline (full RGBA blur).
1000    CompositeBlur {
1001        off: u64,
1002        cnt: u32,
1003        layer_id: u32,
1004    },
1005}
1006
1007enum ImageTex {
1008    Rgba {
1009        tex: wgpu::Texture,
1010        view: wgpu::TextureView,
1011        bind: wgpu::BindGroup,
1012        w: u32,
1013        h: u32,
1014        format: wgpu::TextureFormat,
1015        last_used_frame: u64,
1016        bytes: u64,
1017    },
1018    Nv12 {
1019        tex_y: wgpu::Texture,
1020        view_y: wgpu::TextureView,
1021        tex_uv: wgpu::Texture,
1022        view_uv: wgpu::TextureView,
1023        bind: wgpu::BindGroup,
1024        yuv_buf: wgpu::Buffer,
1025        w: u32,
1026        h: u32,
1027        color_info: ColorInfo,
1028        last_used_frame: u64,
1029        bytes: u64,
1030    },
1031}
1032
1033struct AtlasA8 {
1034    tex: wgpu::Texture,
1035    view: wgpu::TextureView,
1036    sampler: wgpu::Sampler,
1037    size: u32,
1038    next_x: u32,
1039    next_y: u32,
1040    row_h: u32,
1041    map: HashMap<(repose_text::GlyphKey, u32), GlyphInfo>,
1042}
1043
1044struct AtlasRGBA {
1045    tex: wgpu::Texture,
1046    view: wgpu::TextureView,
1047    sampler: wgpu::Sampler,
1048    size: u32,
1049    next_x: u32,
1050    next_y: u32,
1051    row_h: u32,
1052    map: HashMap<(repose_text::GlyphKey, u32), GlyphInfo>,
1053}
1054
1055#[derive(Clone, Copy)]
1056struct GlyphInfo {
1057    u0: f32,
1058    v0: f32,
1059    u1: f32,
1060    v1: f32,
1061    w: f32,
1062    h: f32,
1063    bearing_x: f32,
1064    bearing_y: f32,
1065    advance: f32,
1066}
1067
1068#[repr(C)]
1069#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1070struct RectInstance {
1071    xywh: [f32; 4],
1072    radii: [f32; 4],
1073    brush_type: u32,
1074    _pad: [f32; 3],
1075    color0: [f32; 4],
1076    color1: [f32; 4],
1077    grad_start: [f32; 2],
1078    grad_end: [f32; 2],
1079    sin_cos: [f32; 2],
1080}
1081
1082#[repr(C)]
1083#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1084struct BorderInstance {
1085    xywh: [f32; 4],
1086    radii: [f32; 4],
1087    stroke: f32,
1088    color: [f32; 4],
1089    sin_cos: [f32; 2],
1090}
1091
1092#[repr(C)]
1093#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1094struct EllipseInstance {
1095    xywh: [f32; 4],
1096    color: [f32; 4],
1097    sin_cos: [f32; 2],
1098}
1099
1100#[repr(C)]
1101#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1102struct EllipseBorderInstance {
1103    xywh: [f32; 4],
1104    stroke: f32,
1105    pad: f32,
1106    color: [f32; 4],
1107    sin_cos: [f32; 2],
1108}
1109
1110#[repr(C)]
1111#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1112struct ArcInstance {
1113    xywh: [f32; 4],
1114    start_angle: f32,
1115    sweep_angle: f32,
1116    stroke: f32,
1117    pad: f32,
1118    color: [f32; 4],
1119    sin_cos: [f32; 2],
1120    cap: f32, // 0=Butt, 1=Round, 2=Square
1121}
1122
1123#[repr(C)]
1124#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1125struct GlyphInstance {
1126    xywh: [f32; 4],
1127    uv: [f32; 4],
1128    color: [f32; 4],
1129    sin_cos: [f32; 2],
1130}
1131
1132#[repr(C)]
1133#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1134struct BlurInstance {
1135    xywh: [f32; 4],
1136    uv: [f32; 4],
1137    color: [f32; 4],
1138    blur_uv: [f32; 2],
1139    sin_cos: [f32; 2],
1140}
1141
1142/// CPU-computed Y′CbCr → R′G′B′ transform uploaded as a uniform buffer.
1143/// Layout matches the WGSL `YuvTransform` struct (4 × vec4<f32>).
1144#[repr(C)]
1145#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1146struct YuvTransformRaw {
1147    row0: [f32; 4],
1148    row1: [f32; 4],
1149    row2: [f32; 4],
1150    b: [f32; 4],
1151}
1152
1153#[repr(C)]
1154#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1155struct Nv12Instance {
1156    xywh: [f32; 4],
1157    uv: [f32; 4],
1158    color: [f32; 4], // tint
1159    uv_x_offset: f32,
1160    sin_cos: [f32; 2],
1161    _pad: [f32; 1],
1162}
1163
1164#[repr(C)]
1165#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1166struct ClipInstance {
1167    xywh: [f32; 4],
1168    radii: [f32; 4],
1169    sin_cos: [f32; 2],
1170}
1171
1172fn swash_to_a8_coverage(content: repose_text::SwashContent, data: &[u8]) -> Option<Vec<u8>> {
1173    match content {
1174        repose_text::SwashContent::Mask => Some(data.to_vec()),
1175        repose_text::SwashContent::SubpixelMask => {
1176            let mut out = Vec::with_capacity(data.len() / 4);
1177            for px in data.chunks_exact(4) {
1178                let r = px[0];
1179                let g = px[1];
1180                let b = px[2];
1181                out.push(r.max(g).max(b));
1182            }
1183            Some(out)
1184        }
1185        repose_text::SwashContent::Color => None,
1186    }
1187}
1188
1189impl WgpuSceneRenderer {
1190    pub fn from_device(
1191        device: wgpu::Device,
1192        queue: wgpu::Queue,
1193        output_format: wgpu::TextureFormat,
1194        msaa_samples: u32,
1195    ) -> Self {
1196        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1197            label: Some("globals layout"),
1198            entries: &[wgpu::BindGroupLayoutEntry {
1199                binding: 0,
1200                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
1201                ty: wgpu::BindingType::Buffer {
1202                    ty: wgpu::BufferBindingType::Uniform,
1203                    has_dynamic_offset: false,
1204                    min_binding_size: None,
1205                },
1206                count: None,
1207            }],
1208        });
1209
1210        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
1211            label: Some("globals buf"),
1212            size: std::mem::size_of::<Globals>() as u64,
1213            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1214            mapped_at_creation: false,
1215        });
1216
1217        let globals_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
1218            label: Some("globals bind"),
1219            layout: &globals_layout,
1220            entries: &[wgpu::BindGroupEntry {
1221                binding: 0,
1222                resource: globals_buf.as_entire_binding(),
1223            }],
1224        });
1225
1226
1227        let ds_format = wgpu::TextureFormat::Depth24PlusStencil8;
1228
1229        let stencil_for_content = wgpu::DepthStencilState {
1230            format: ds_format,
1231            depth_write_enabled: Some(false),
1232            depth_compare: Some(wgpu::CompareFunction::Always),
1233            stencil: wgpu::StencilState {
1234                front: wgpu::StencilFaceState {
1235                    compare: wgpu::CompareFunction::LessEqual,
1236                    fail_op: wgpu::StencilOperation::Keep,
1237                    depth_fail_op: wgpu::StencilOperation::Keep,
1238                    pass_op: wgpu::StencilOperation::Keep,
1239                },
1240                back: wgpu::StencilFaceState {
1241                    compare: wgpu::CompareFunction::LessEqual,
1242                    fail_op: wgpu::StencilOperation::Keep,
1243                    depth_fail_op: wgpu::StencilOperation::Keep,
1244                    pass_op: wgpu::StencilOperation::Keep,
1245                },
1246                read_mask: 0xFF,
1247                write_mask: 0x00,
1248            },
1249            bias: wgpu::DepthBiasState::default(),
1250        };
1251
1252        let stencil_for_clip_inc = wgpu::DepthStencilState {
1253            format: ds_format,
1254            depth_write_enabled: Some(false),
1255            depth_compare: Some(wgpu::CompareFunction::Always),
1256            stencil: wgpu::StencilState {
1257                front: wgpu::StencilFaceState {
1258                    compare: wgpu::CompareFunction::Equal,
1259                    fail_op: wgpu::StencilOperation::Keep,
1260                    depth_fail_op: wgpu::StencilOperation::Keep,
1261                    pass_op: wgpu::StencilOperation::IncrementClamp,
1262                },
1263                back: wgpu::StencilFaceState {
1264                    compare: wgpu::CompareFunction::Equal,
1265                    fail_op: wgpu::StencilOperation::Keep,
1266                    depth_fail_op: wgpu::StencilOperation::Keep,
1267                    pass_op: wgpu::StencilOperation::IncrementClamp,
1268                },
1269                read_mask: 0xFF,
1270                write_mask: 0xFF,
1271            },
1272            bias: wgpu::DepthBiasState::default(),
1273        };
1274
1275        let stencil_for_clip_dec = wgpu::DepthStencilState {
1276            format: ds_format,
1277            depth_write_enabled: Some(false),
1278            depth_compare: Some(wgpu::CompareFunction::Always),
1279            stencil: wgpu::StencilState {
1280                front: wgpu::StencilFaceState {
1281                    compare: wgpu::CompareFunction::Equal,
1282                    fail_op: wgpu::StencilOperation::Keep,
1283                    depth_fail_op: wgpu::StencilOperation::Keep,
1284                    pass_op: wgpu::StencilOperation::DecrementClamp,
1285                },
1286                back: wgpu::StencilFaceState {
1287                    compare: wgpu::CompareFunction::Equal,
1288                    fail_op: wgpu::StencilOperation::Keep,
1289                    depth_fail_op: wgpu::StencilOperation::Keep,
1290                    pass_op: wgpu::StencilOperation::DecrementClamp,
1291                },
1292                read_mask: 0xFF,
1293                write_mask: 0xFF,
1294            },
1295            bias: wgpu::DepthBiasState::default(),
1296        };
1297
1298        let _multisample_state = wgpu::MultisampleState {
1299            count: msaa_samples,
1300            mask: !0,
1301            alpha_to_coverage_enabled: false,
1302        };
1303
1304        // PIPELINES
1305
1306        // Single shared sampler for images/text
1307        let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1308            label: Some("image/text sampler"),
1309            address_mode_u: wgpu::AddressMode::ClampToEdge,
1310            address_mode_v: wgpu::AddressMode::ClampToEdge,
1311            mag_filter: wgpu::FilterMode::Linear,
1312            min_filter: wgpu::FilterMode::Linear,
1313            mipmap_filter: wgpu::MipmapFilterMode::Linear,
1314            ..Default::default()
1315        });
1316
1317        // linear filtering only blurs them; nearest keeps the blit crisp.
1318        let layer_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1319            label: Some("layer nearest sampler"),
1320            address_mode_u: wgpu::AddressMode::ClampToEdge,
1321            address_mode_v: wgpu::AddressMode::ClampToEdge,
1322            mag_filter: wgpu::FilterMode::Nearest,
1323            min_filter: wgpu::FilterMode::Nearest,
1324            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
1325            ..Default::default()
1326        });
1327
1328        // Linear taps for Gaussian blur/shadow passes; nearest is kept for
1329        // the sharp 1:1 layer composite.
1330        let layer_sampler_linear = device.create_sampler(&wgpu::SamplerDescriptor {
1331            label: Some("layer linear sampler"),
1332            address_mode_u: wgpu::AddressMode::ClampToEdge,
1333            address_mode_v: wgpu::AddressMode::ClampToEdge,
1334            mag_filter: wgpu::FilterMode::Linear,
1335            min_filter: wgpu::FilterMode::Linear,
1336            mipmap_filter: wgpu::MipmapFilterMode::Linear,
1337            ..Default::default()
1338        });
1339
1340        // Layout for Text / RGBA Images (Texture + Sampler)
1341        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1342            label: Some("text/rgba bind layout"),
1343            entries: &[
1344                wgpu::BindGroupLayoutEntry {
1345                    binding: 0,
1346                    visibility: wgpu::ShaderStages::FRAGMENT,
1347                    ty: wgpu::BindingType::Texture {
1348                        multisampled: false,
1349                        view_dimension: wgpu::TextureViewDimension::D2,
1350                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
1351                    },
1352                    count: None,
1353                },
1354                wgpu::BindGroupLayoutEntry {
1355                    binding: 1,
1356                    visibility: wgpu::ShaderStages::FRAGMENT,
1357                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1358                    count: None,
1359                },
1360            ],
1361        });
1362        // We reuse this for RGBA images for simplicity, or create a distinct one
1363        let image_bind_layout_rgba = text_bind_layout.clone();
1364
1365        // Layout for NV12 Images (TextureY + TextureUV + Sampler + YuvTransform uniform)
1366        let image_bind_layout_nv12 =
1367            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1368                label: Some("image bind layout nv12"),
1369                entries: &[
1370                    // Y plane
1371                    wgpu::BindGroupLayoutEntry {
1372                        binding: 0,
1373                        visibility: wgpu::ShaderStages::FRAGMENT,
1374                        ty: wgpu::BindingType::Texture {
1375                            multisampled: false,
1376                            view_dimension: wgpu::TextureViewDimension::D2,
1377                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1378                        },
1379                        count: None,
1380                    },
1381                    // UV plane
1382                    wgpu::BindGroupLayoutEntry {
1383                        binding: 1,
1384                        visibility: wgpu::ShaderStages::FRAGMENT,
1385                        ty: wgpu::BindingType::Texture {
1386                            multisampled: false,
1387                            view_dimension: wgpu::TextureViewDimension::D2,
1388                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1389                        },
1390                        count: None,
1391                    },
1392                    // Sampler
1393                    wgpu::BindGroupLayoutEntry {
1394                        binding: 2,
1395                        visibility: wgpu::ShaderStages::FRAGMENT,
1396                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1397                        count: None,
1398                    },
1399                    // YUV transform uniform buffer
1400                    wgpu::BindGroupLayoutEntry {
1401                        binding: 3,
1402                        visibility: wgpu::ShaderStages::FRAGMENT,
1403                        ty: wgpu::BindingType::Buffer {
1404                            ty: wgpu::BufferBindingType::Uniform,
1405                            has_dynamic_offset: false,
1406                            min_binding_size: None,
1407                        },
1408                        count: None,
1409                    },
1410                ],
1411            });
1412
1413        // Clipping layout
1414        let clip_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1415            label: Some("clip pipeline layout"),
1416            bind_group_layouts: &[Some(&globals_layout)],
1417            immediate_size: 0,
1418        });
1419        let clip_vertex_layout = wgpu::VertexBufferLayout {
1420            array_stride: std::mem::size_of::<ClipInstance>() as u64,
1421            step_mode: wgpu::VertexStepMode::Instance,
1422            attributes: &[
1423                wgpu::VertexAttribute {
1424                    shader_location: 0,
1425                    offset: 0,
1426                    format: wgpu::VertexFormat::Float32x4,
1427                },
1428                wgpu::VertexAttribute {
1429                    shader_location: 1,
1430                    offset: 16,
1431                    format: wgpu::VertexFormat::Float32x4,
1432                },
1433                wgpu::VertexAttribute {
1434                    shader_location: 2,
1435                    offset: 32,
1436                    format: wgpu::VertexFormat::Float32x2,
1437                },
1438            ],
1439        };
1440        let clip_color_target = wgpu::ColorTargetState {
1441            format: output_format,
1442            blend: None,
1443            write_mask: wgpu::ColorWrites::empty(),
1444        };
1445
1446        // Two sets of pipelines: one for the MSAA surface pass, one for layer
1447        // render-to-texture passes (sample_count = 1).
1448        let surface_pipes = Pipelines::create(
1449            &device,
1450            output_format,
1451            msaa_samples,
1452            &globals_layout,
1453            &text_bind_layout,
1454            &image_bind_layout_nv12,
1455            &clip_pipeline_layout,
1456            &stencil_for_content,
1457            &stencil_for_clip_inc,
1458            &stencil_for_clip_dec,
1459            &clip_color_target,
1460            &clip_vertex_layout,
1461        );
1462        let layer_pipes = Pipelines::create(
1463            &device,
1464            output_format,
1465            1,
1466            &globals_layout,
1467            &text_bind_layout,
1468            &image_bind_layout_nv12,
1469            &clip_pipeline_layout,
1470            &stencil_for_content,
1471            &stencil_for_clip_inc,
1472            &stencil_for_clip_dec,
1473            &clip_color_target,
1474            &clip_vertex_layout,
1475        );
1476
1477        // Vector glyph rendering always available with tessellation+MSAA approach.
1478        let slug_enabled = true;
1479
1480        // Blur composite ring (for graphics-layer drop shadows)
1481        let blur_ring = UploadRing::new(&device, "blur ring", 1024 * 1024);
1482
1483        // Atlases
1484        let atlas_mask = init_atlas_mask(&device);
1485        let atlas_color = init_atlas_color(&device);
1486
1487        // Upload rings
1488        let ring_rect = UploadRing::new(&device, "ring rect", 1 << 20);
1489        let ring_border = UploadRing::new(&device, "ring border", 1 << 20);
1490        let ring_ellipse = UploadRing::new(&device, "ring ellipse", 1 << 20);
1491        let ring_ellipse_border = UploadRing::new(&device, "ring ellipse border", 1 << 20);
1492        let ring_arc = UploadRing::new(&device, "ring arc", 1 << 20);
1493        let ring_glyph_mask = UploadRing::new(&device, "ring glyph mask", 1 << 20);
1494        let ring_glyph_color = UploadRing::new(&device, "ring glyph color", 1 << 20);
1495        let ring_slug = UploadRing::new(&device, "ring slug", 1 << 22);
1496        let ring_clip = UploadRing::new(&device, "ring clip", 1 << 16);
1497        let ring_nv12 = UploadRing::new(&device, "ring nv12", 1 << 20);
1498
1499        // Placeholder textures
1500        let depth_stencil_tex = device.create_texture(&wgpu::TextureDescriptor {
1501            label: Some("temp ds"),
1502            size: wgpu::Extent3d {
1503                width: 1,
1504                height: 1,
1505                depth_or_array_layers: 1,
1506            },
1507            mip_level_count: 1,
1508            sample_count: 1,
1509            dimension: wgpu::TextureDimension::D2,
1510            format: wgpu::TextureFormat::Depth24PlusStencil8,
1511            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1512            view_formats: &[],
1513        });
1514        let depth_stencil_view =
1515            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
1516
1517        let mut renderer = WgpuSceneRenderer {
1518            device,
1519            queue,
1520            output_format,
1521            output_width: 0,
1522            output_height: 0,
1523
1524            surface_pipes,
1525            layer_pipes,
1526
1527            rects: InstancedPipe::new(ring_rect),
1528            borders: InstancedPipe::new(ring_border),
1529            ellipses: InstancedPipe::new(ring_ellipse),
1530            ellipse_borders: InstancedPipe::new(ring_ellipse_border),
1531            arcs: InstancedPipe::new(ring_arc),
1532            glyph_mask: InstancedPipe::new(ring_glyph_mask),
1533            glyph_color: InstancedPipe::new(ring_glyph_color),
1534
1535            text_bind_layout,
1536
1537            image_bind_layout_rgba,
1538            image_bind_layout_nv12,
1539            image_sampler,
1540            layer_sampler,
1541            layer_sampler_linear,
1542
1543            blur_ring,
1544
1545            slug_enabled,
1546            slug_ring: ring_slug,
1547            slug_cache: slug::GlyphSlugCache::new(),
1548
1549            clip_ring: ring_clip,
1550
1551            nv12: InstancedPipe::new(ring_nv12),
1552
1553            msaa_samples,
1554            depth_stencil_tex,
1555            depth_stencil_view,
1556            msaa_tex: None,
1557            msaa_view: None,
1558            globals_bind,
1559            globals_buf,
1560            globals_layout,
1561
1562            atlas_mask,
1563            atlas_color,
1564
1565            next_image_handle: 1,
1566            images: HashMap::new(),
1567
1568            frame_index: 0,
1569            image_bytes_total: 0,
1570            image_evict_after_frames: 600,         // ~10s @ 60fps
1571            image_budget_bytes: 512 * 1024 * 1024, // 512 MB
1572            layer_pool: HashMap::new(),
1573
1574            working_space: false,
1575            ws_tex: None,
1576            ws_view: None,
1577            ws_bind: None,
1578            display_pipeline: None,
1579            display_layout: None,
1580        };
1581
1582        renderer.recreate_msaa_and_depth_stencil();
1583        renderer
1584    }
1585}
1586
1587impl WgpuSurfaceBackend {
1588    #[cfg(feature = "winit-surface")]
1589    pub async fn new_async(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
1590        Self::new_async_with_options(window, 4, PresentModePref::Auto).await
1591    }
1592
1593    /// Create a windowed surface backend, honoring the requested MSAA sample
1594    /// count (falling back to the largest supported count <= `msaa_samples`).
1595    #[cfg(feature = "winit-surface")]
1596    pub async fn new_async_with_msaa(
1597        window: Arc<winit::window::Window>,
1598        msaa_samples: u32,
1599    ) -> anyhow::Result<WgpuSurfaceBackend> {
1600        Self::new_async_with_options(window, msaa_samples, PresentModePref::Auto).await
1601    }
1602
1603    /// Create a windowed surface backend, honoring the requested MSAA sample
1604    /// count and present-mode preference.
1605    #[cfg(feature = "winit-surface")]
1606    pub async fn new_async_with_options(
1607        window: Arc<winit::window::Window>,
1608        msaa_samples: u32,
1609        present_mode: PresentModePref,
1610    ) -> anyhow::Result<WgpuSurfaceBackend> {
1611        let instance: Instance;
1612
1613        if cfg!(target_arch = "wasm32") {
1614            let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
1615            desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
1616            instance = wgpu::util::new_instance_with_webgpu_detection(desc).await;
1617        } else {
1618            instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1619        };
1620
1621        let surface = instance.create_surface(window.clone())?;
1622
1623        let adapter = instance
1624            .request_adapter(&wgpu::RequestAdapterOptions {
1625                power_preference: wgpu::PowerPreference::HighPerformance,
1626                compatible_surface: Some(&surface),
1627                force_fallback_adapter: false,
1628                apply_limit_buckets: false,
1629            })
1630            .await
1631            .map_err(|e| anyhow::anyhow!("No suitable adapter: {e:?}"))?;
1632
1633        let limits = adapter.limits();
1634
1635        #[cfg(target_os = "linux")]
1636        let features = {
1637            let af = adapter.features();
1638            let mut f = wgpu::Features::empty();
1639            if af.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD) {
1640                f |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_FD;
1641            }
1642            if af.contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) {
1643                f |= wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF;
1644            }
1645            f
1646        };
1647        #[cfg(not(target_os = "linux"))]
1648        let features = wgpu::Features::empty();
1649
1650        let (device, queue) = adapter
1651            .request_device(&wgpu::DeviceDescriptor {
1652                label: Some("repose-rs device"),
1653                required_features: features,
1654                required_limits: limits,
1655                experimental_features: wgpu::ExperimentalFeatures::disabled(),
1656                memory_hints: wgpu::MemoryHints::default(),
1657                trace: wgpu::Trace::Off,
1658            })
1659            .await
1660            .map_err(|e| anyhow::anyhow!("request_device failed: {e:?}"))?;
1661
1662        let size = window.inner_size();
1663
1664        let caps = surface.get_capabilities(&adapter);
1665        let format = caps
1666            .formats
1667            .iter()
1668            .copied()
1669            .find(|f| f.is_srgb())
1670            .unwrap_or(caps.formats[0]);
1671        let present_mode = pick_present_mode(&caps, present_mode);
1672        let alpha_mode = caps.alpha_modes[0];
1673
1674        // Pick MSAA sample count, honoring the requested value. The depth
1675        // target is created at the same sample count and the MSAA color target
1676        // resolves to the surface, so both must support the count.
1677        let msaa_samples = pick_surface_msaa(&adapter, format, msaa_samples);
1678
1679        let renderer = WgpuSceneRenderer::from_device(device, queue, format, msaa_samples);
1680
1681        let config = wgpu::SurfaceConfiguration {
1682            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1683            format,
1684            width: size.width.max(1),
1685            height: size.height.max(1),
1686            present_mode,
1687            alpha_mode,
1688            color_space: wgpu::SurfaceColorSpace::Auto,
1689            view_formats: vec![],
1690            desired_maximum_frame_latency: 1,
1691        };
1692        surface.configure(&renderer.device, &config);
1693
1694        Ok(WgpuSurfaceBackend { surface: Some(surface), surface_config: Some(config), renderer })
1695    }
1696
1697    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
1698    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
1699        pollster::block_on(Self::new_async(window))
1700    }
1701
1702    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
1703    pub fn new_with_msaa(
1704        window: Arc<winit::window::Window>,
1705        msaa_samples: u32,
1706    ) -> anyhow::Result<WgpuSurfaceBackend> {
1707        pollster::block_on(Self::new_async_with_msaa(window, msaa_samples))
1708    }
1709
1710    #[cfg(all(feature = "winit-surface", not(target_arch = "wasm32")))]
1711    pub fn new_with_options(
1712        window: Arc<winit::window::Window>,
1713        msaa_samples: u32,
1714        present_mode: PresentModePref,
1715    ) -> anyhow::Result<WgpuSurfaceBackend> {
1716        pollster::block_on(Self::new_async_with_options(
1717            window,
1718            msaa_samples,
1719            present_mode,
1720        ))
1721    }
1722
1723    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
1724    pub fn new(_window: Arc<winit::window::Window>) -> anyhow::Result<WgpuSurfaceBackend> {
1725        anyhow::bail!("Use WgpuSurfaceBackend::new_async(window).await on wasm32")
1726    }
1727
1728    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
1729    pub fn new_with_msaa(
1730        _window: Arc<winit::window::Window>,
1731        _msaa_samples: u32,
1732    ) -> anyhow::Result<WgpuSurfaceBackend> {
1733        anyhow::bail!("Use WgpuSurfaceBackend::new_async_with_msaa(window, msaa).await on wasm32")
1734    }
1735
1736    #[cfg(all(feature = "winit-surface", target_arch = "wasm32"))]
1737    pub fn new_with_options(
1738        _window: Arc<winit::window::Window>,
1739        _msaa_samples: u32,
1740        _present_mode: PresentModePref,
1741    ) -> anyhow::Result<WgpuSurfaceBackend> {
1742        anyhow::bail!(
1743            "Use WgpuSurfaceBackend::new_async_with_options(window, msaa, mode).await on wasm32"
1744        )
1745    }
1746}
1747
1748/// Pick the swapchain present mode honoring `pref`, falling back to an "auto"
1749/// Fifo-first selection when the preferred mode is unavailable.
1750fn pick_present_mode(caps: &wgpu::SurfaceCapabilities, pref: PresentModePref) -> wgpu::PresentMode {
1751    let auto = || {
1752        caps.present_modes
1753            .iter()
1754            .copied()
1755            .find(|m| *m == wgpu::PresentMode::Fifo)
1756            .or_else(|| {
1757                caps.present_modes
1758                    .iter()
1759                    .copied()
1760                    .find(|m| *m == wgpu::PresentMode::Mailbox)
1761            })
1762            .unwrap_or(wgpu::PresentMode::Immediate)
1763    };
1764    match pref {
1765        PresentModePref::Auto => auto(),
1766        PresentModePref::Fifo if caps.present_modes.contains(&wgpu::PresentMode::Fifo) => {
1767            wgpu::PresentMode::Fifo
1768        }
1769        PresentModePref::Mailbox if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) => {
1770            wgpu::PresentMode::Mailbox
1771        }
1772        PresentModePref::Immediate
1773            if caps.present_modes.contains(&wgpu::PresentMode::Immediate) =>
1774        {
1775            wgpu::PresentMode::Immediate
1776        }
1777        _ => auto(),
1778    }
1779}
1780
1781/// Pick the MSAA sample count for the surface pass, honoring `requested` and
1782/// falling back to the largest supported count <= it.
1783fn pick_surface_msaa(adapter: &wgpu::Adapter, format: wgpu::TextureFormat, requested: u32) -> u32 {
1784    let requested = requested.max(1);
1785    let color_feat = adapter.get_texture_format_features(format);
1786    let depth_feat = adapter.get_texture_format_features(wgpu::TextureFormat::Depth24PlusStencil8);
1787    let supported = |n: u32| {
1788        color_feat.flags.sample_count_supported(n)
1789            && color_feat
1790                .flags
1791                .contains(wgpu::TextureFormatFeatureFlags::MULTISAMPLE_RESOLVE)
1792            && depth_feat.flags.sample_count_supported(n)
1793    };
1794    let mut candidates = vec![requested];
1795    for n in [8, 4, 2, 1] {
1796        if n < requested {
1797            candidates.push(n);
1798        }
1799    }
1800    let chosen = candidates.into_iter().find(|&n| supported(n)).unwrap_or(1);
1801    if chosen != requested {
1802        log::info!("requested MSAA x{requested}, using x{chosen}");
1803    }
1804    chosen
1805}
1806
1807impl WgpuSceneRenderer {
1808    // Image API
1809
1810    pub fn set_image_from_bytes(
1811        &mut self,
1812        handle: u64,
1813        data: &[u8],
1814        srgb: bool,
1815    ) -> anyhow::Result<()> {
1816        let img = image::load_from_memory(data)?;
1817        let rgba = img.to_rgba8();
1818        let (w, h) = rgba.dimensions();
1819        self.set_image_rgba8(handle, w, h, &rgba, srgb)
1820    }
1821
1822    pub fn set_image_rgba8(
1823        &mut self,
1824        handle: u64,
1825        w: u32,
1826        h: u32,
1827        rgba: &[u8],
1828        srgb: bool,
1829    ) -> anyhow::Result<()> {
1830        let expected = (w as usize) * (h as usize) * 4;
1831        if rgba.len() < expected {
1832            return Err(anyhow::anyhow!(
1833                "RGBA buffer too small: {} < {}",
1834                rgba.len(),
1835                expected
1836            ));
1837        }
1838
1839        let format = if srgb {
1840            wgpu::TextureFormat::Rgba8UnormSrgb
1841        } else {
1842            wgpu::TextureFormat::Rgba8Unorm
1843        };
1844
1845        let needs_recreate = match self.images.get(&handle) {
1846            Some(ImageTex::Rgba {
1847                w: cw,
1848                h: ch,
1849                format: cf,
1850                ..
1851            }) => *cw != w || *ch != h || *cf != format,
1852            _ => true,
1853        };
1854
1855        if needs_recreate {
1856            // Remove old to track budget correctly
1857            self.remove_image(handle);
1858
1859            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
1860                label: Some("user image rgba"),
1861                size: wgpu::Extent3d {
1862                    width: w,
1863                    height: h,
1864                    depth_or_array_layers: 1,
1865                },
1866                mip_level_count: 1,
1867                sample_count: 1,
1868                dimension: wgpu::TextureDimension::D2,
1869                format,
1870                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1871                view_formats: &[],
1872            });
1873            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
1874
1875            let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1876                label: Some("image bind rgba"),
1877                layout: &self.image_bind_layout_rgba,
1878                entries: &[
1879                    wgpu::BindGroupEntry {
1880                        binding: 0,
1881                        resource: wgpu::BindingResource::TextureView(&view),
1882                    },
1883                    wgpu::BindGroupEntry {
1884                        binding: 1,
1885                        resource: wgpu::BindingResource::Sampler(&self.image_sampler),
1886                    },
1887                ],
1888            });
1889
1890            let bytes = (w as u64) * (h as u64) * 4;
1891            self.image_bytes_total += bytes;
1892
1893            self.images.insert(
1894                handle,
1895                ImageTex::Rgba {
1896                    tex,
1897                    view,
1898                    bind,
1899                    w,
1900                    h,
1901                    format,
1902                    last_used_frame: self.frame_index,
1903                    bytes,
1904                },
1905            );
1906        }
1907
1908        let tex = match self.images.get(&handle) {
1909            Some(ImageTex::Rgba { tex, .. }) => tex,
1910            _ => unreachable!(),
1911        };
1912
1913        self.queue.write_texture(
1914            wgpu::TexelCopyTextureInfo {
1915                texture: tex,
1916                mip_level: 0,
1917                origin: wgpu::Origin3d::ZERO,
1918                aspect: wgpu::TextureAspect::All,
1919            },
1920            &rgba[..expected],
1921            wgpu::TexelCopyBufferLayout {
1922                offset: 0,
1923                bytes_per_row: Some(4 * w),
1924                rows_per_image: Some(h),
1925            },
1926            wgpu::Extent3d {
1927                width: w,
1928                height: h,
1929                depth_or_array_layers: 1,
1930            },
1931        );
1932
1933        // Ensure budget limits
1934        self.evict_budget_excess();
1935
1936        Ok(())
1937    }
1938
1939    pub fn set_image_nv12(
1940        &mut self,
1941        handle: u64,
1942        w: u32,
1943        h: u32,
1944        y: &[u8],
1945        uv: &[u8],
1946        color_info: ColorInfo,
1947    ) -> anyhow::Result<()> {
1948        let y_expected = (w as usize) * (h as usize);
1949        let uv_w = w.div_ceil(2);
1950        let uv_h = h.div_ceil(2);
1951        let uv_expected = (uv_w as usize) * (uv_h as usize) * 2;
1952
1953        if y.len() < y_expected {
1954            return Err(anyhow::anyhow!("Y plane too small"));
1955        }
1956        if uv.len() < uv_expected {
1957            return Err(anyhow::anyhow!("UV plane too small"));
1958        }
1959
1960        let needs_recreate = match self.images.get(&handle) {
1961            Some(ImageTex::Nv12 { w: ww, h: hh, .. }) => *ww != w || *hh != h,
1962            _ => true,
1963        };
1964
1965        // Compute the YUV→RGB transform on the CPU.
1966        let yuv = color_info.to_yuv_transform();
1967        let yuv_raw = YuvTransformRaw {
1968            row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
1969            row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
1970            row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
1971            b: [yuv.b[0], yuv.b[1], yuv.b[2], 0.0],
1972        };
1973
1974        if needs_recreate {
1975            self.remove_image(handle);
1976
1977            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
1978                label: Some("nv12 Y"),
1979                size: wgpu::Extent3d {
1980                    width: w,
1981                    height: h,
1982                    depth_or_array_layers: 1,
1983                },
1984                mip_level_count: 1,
1985                sample_count: 1,
1986                dimension: wgpu::TextureDimension::D2,
1987                format: wgpu::TextureFormat::R8Unorm,
1988                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1989                view_formats: &[],
1990            });
1991            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
1992
1993            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
1994                label: Some("nv12 UV"),
1995                size: wgpu::Extent3d {
1996                    width: uv_w,
1997                    height: uv_h,
1998                    depth_or_array_layers: 1,
1999                },
2000                mip_level_count: 1,
2001                sample_count: 1,
2002                dimension: wgpu::TextureDimension::D2,
2003                format: wgpu::TextureFormat::Rg8Unorm,
2004                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
2005                view_formats: &[],
2006            });
2007            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
2008
2009            // Create a uniform buffer for the YUV transform (per-image).
2010            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2011                label: Some("nv12 yuv transform"),
2012                size: std::mem::size_of::<YuvTransformRaw>() as u64,
2013                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2014                mapped_at_creation: false,
2015            });
2016
2017            // Write initial transform.
2018            self.queue
2019                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
2020
2021            let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2022                label: Some("nv12 bind"),
2023                layout: &self.image_bind_layout_nv12,
2024                entries: &[
2025                    wgpu::BindGroupEntry {
2026                        binding: 0,
2027                        resource: wgpu::BindingResource::TextureView(&view_y),
2028                    },
2029                    wgpu::BindGroupEntry {
2030                        binding: 1,
2031                        resource: wgpu::BindingResource::TextureView(&view_uv),
2032                    },
2033                    wgpu::BindGroupEntry {
2034                        binding: 2,
2035                        resource: wgpu::BindingResource::Sampler(&self.image_sampler),
2036                    },
2037                    wgpu::BindGroupEntry {
2038                        binding: 3,
2039                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2040                            buffer: &yuv_buf,
2041                            offset: 0,
2042                            size: None,
2043                        }),
2044                    },
2045                ],
2046            });
2047
2048            let bytes = (w as u64) * (h as u64)
2049                + (uv_w as u64) * (uv_h as u64) * 2
2050                + std::mem::size_of::<YuvTransformRaw>() as u64;
2051            self.image_bytes_total += bytes;
2052
2053            self.images.insert(
2054                handle,
2055                ImageTex::Nv12 {
2056                    tex_y,
2057                    view_y,
2058                    tex_uv,
2059                    view_uv,
2060                    bind,
2061                    yuv_buf,
2062                    w,
2063                    h,
2064                    color_info,
2065                    last_used_frame: self.frame_index,
2066                    bytes,
2067                },
2068            );
2069        } else {
2070            // Re-use existing textures; just update the YUV transform if needed.
2071            if let Some(ImageTex::Nv12 { yuv_buf, .. }) = self.images.get(&handle) {
2072                self.queue
2073                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
2074            }
2075        }
2076
2077        let (tex_y, tex_uv, _bind) = match self.images.get(&handle) {
2078            Some(ImageTex::Nv12 {
2079                tex_y,
2080                tex_uv,
2081                bind,
2082                ..
2083            }) => (tex_y, tex_uv, bind),
2084            _ => return Err(anyhow::anyhow!("Handle is not NV12")),
2085        };
2086
2087        self.queue.write_texture(
2088            wgpu::TexelCopyTextureInfo {
2089                texture: tex_y,
2090                mip_level: 0,
2091                origin: wgpu::Origin3d::ZERO,
2092                aspect: wgpu::TextureAspect::All,
2093            },
2094            &y[..y_expected],
2095            wgpu::TexelCopyBufferLayout {
2096                offset: 0,
2097                bytes_per_row: Some(w),
2098                rows_per_image: Some(h),
2099            },
2100            wgpu::Extent3d {
2101                width: w,
2102                height: h,
2103                depth_or_array_layers: 1,
2104            },
2105        );
2106
2107        self.queue.write_texture(
2108            wgpu::TexelCopyTextureInfo {
2109                texture: tex_uv,
2110                mip_level: 0,
2111                origin: wgpu::Origin3d::ZERO,
2112                aspect: wgpu::TextureAspect::All,
2113            },
2114            &uv[..uv_expected],
2115            wgpu::TexelCopyBufferLayout {
2116                offset: 0,
2117                bytes_per_row: Some(2 * uv_w),
2118                rows_per_image: Some(uv_h),
2119            },
2120            wgpu::Extent3d {
2121                width: uv_w,
2122                height: uv_h,
2123                depth_or_array_layers: 1,
2124            },
2125        );
2126
2127        self.evict_budget_excess();
2128        Ok(())
2129    }
2130
2131    pub fn set_image_planes(
2132        &mut self,
2133        handle: u64,
2134        w: u32,
2135        h: u32,
2136        pixel_format: PixelFormat,
2137        planes: &[&[u8]],
2138        color_info: ColorInfo,
2139    ) -> anyhow::Result<()> {
2140        match pixel_format {
2141            PixelFormat::Nv12 => {
2142                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
2143                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
2144                self.set_image_nv12(handle, w, h, y, uv, color_info)
2145            }
2146            PixelFormat::P010 => {
2147                let y = planes.first().ok_or(anyhow::anyhow!("missing Y plane"))?;
2148                let uv = planes.get(1).ok_or(anyhow::anyhow!("missing UV plane"))?;
2149                self.set_image_p010(handle, w, h, y, uv, color_info)
2150            }
2151            PixelFormat::I420 | PixelFormat::I444 => Err(anyhow::anyhow!(
2152                "I420/I444 not implemented and unlikely -> cheap to convert to NV12 (better for the GPU too)"
2153            )),
2154            PixelFormat::Rgba => {
2155                let rgba = planes
2156                    .first()
2157                    .ok_or(anyhow::anyhow!("missing RGBA plane"))?;
2158                self.set_image_rgba8(handle, w, h, rgba, false)
2159            }
2160        }
2161    }
2162
2163    fn set_image_p010(
2164        &mut self,
2165        handle: u64,
2166        w: u32,
2167        h: u32,
2168        y: &[u8],
2169        uv: &[u8],
2170        color_info: ColorInfo,
2171    ) -> anyhow::Result<()> {
2172        let uv_w = w.div_ceil(2);
2173        let uv_h = h.div_ceil(2);
2174
2175        let y_expected = (w as usize) * 2;
2176        let uv_expected = (uv_w as usize) * (uv_h as usize) * 4;
2177
2178        if y.len() < y_expected {
2179            return Err(anyhow::anyhow!("P010 Y plane too small"));
2180        }
2181        if uv.len() < uv_expected {
2182            return Err(anyhow::anyhow!("P010 UV plane too small"));
2183        }
2184
2185        // P010 reuses the NV12 pipeline (same bind group layout -> wgpu
2186        // abstracts the storage format so R16Unorm/Rg16Unorm are
2187        // filterable float textures just like R8Unorm/Rg8Unorm).
2188        let needs_recreate = match self.images.get(&handle) {
2189            Some(ImageTex::Nv12 { w: ww, h: hh, .. }) => *ww != w || *hh != h,
2190            _ => true,
2191        };
2192
2193        let yuv = color_info.to_yuv_transform();
2194        let yuv_raw = YuvTransformRaw {
2195            row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2196            row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2197            row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2198            b: [yuv.b[0], yuv.b[1], yuv.b[2], 0.0],
2199        };
2200
2201        if needs_recreate {
2202            self.remove_image(handle);
2203
2204            let tex_y = self.device.create_texture(&wgpu::TextureDescriptor {
2205                label: Some("p010 Y"),
2206                size: wgpu::Extent3d {
2207                    width: w,
2208                    height: h,
2209                    depth_or_array_layers: 1,
2210                },
2211                mip_level_count: 1,
2212                sample_count: 1,
2213                dimension: wgpu::TextureDimension::D2,
2214                format: wgpu::TextureFormat::R16Unorm,
2215                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
2216                view_formats: &[],
2217            });
2218            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
2219
2220            let tex_uv = self.device.create_texture(&wgpu::TextureDescriptor {
2221                label: Some("p010 UV"),
2222                size: wgpu::Extent3d {
2223                    width: uv_w,
2224                    height: uv_h,
2225                    depth_or_array_layers: 1,
2226                },
2227                mip_level_count: 1,
2228                sample_count: 1,
2229                dimension: wgpu::TextureDimension::D2,
2230                format: wgpu::TextureFormat::Rg16Unorm,
2231                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
2232                view_formats: &[],
2233            });
2234            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
2235
2236            let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2237                label: Some("p010 yuv transform"),
2238                size: std::mem::size_of::<YuvTransformRaw>() as u64,
2239                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2240                mapped_at_creation: false,
2241            });
2242            self.queue
2243                .write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
2244
2245            let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2246                label: Some("p010 bind"),
2247                layout: &self.image_bind_layout_nv12,
2248                entries: &[
2249                    wgpu::BindGroupEntry {
2250                        binding: 0,
2251                        resource: wgpu::BindingResource::TextureView(&view_y),
2252                    },
2253                    wgpu::BindGroupEntry {
2254                        binding: 1,
2255                        resource: wgpu::BindingResource::TextureView(&view_uv),
2256                    },
2257                    wgpu::BindGroupEntry {
2258                        binding: 2,
2259                        resource: wgpu::BindingResource::Sampler(&self.image_sampler),
2260                    },
2261                    wgpu::BindGroupEntry {
2262                        binding: 3,
2263                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2264                            buffer: &yuv_buf,
2265                            offset: 0,
2266                            size: None,
2267                        }),
2268                    },
2269                ],
2270            });
2271
2272            let bytes = (w as u64) * 2
2273                + (uv_w as u64) * (uv_h as u64) * 4
2274                + std::mem::size_of::<YuvTransformRaw>() as u64;
2275            self.image_bytes_total += bytes;
2276
2277            self.images.insert(
2278                handle,
2279                ImageTex::Nv12 {
2280                    tex_y,
2281                    view_y,
2282                    tex_uv,
2283                    view_uv,
2284                    bind,
2285                    yuv_buf,
2286                    w,
2287                    h,
2288                    color_info,
2289                    last_used_frame: self.frame_index,
2290                    bytes,
2291                },
2292            );
2293        } else {
2294            if let Some(ImageTex::Nv12 { yuv_buf, .. }) = self.images.get(&handle) {
2295                self.queue
2296                    .write_buffer(yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
2297            }
2298        }
2299
2300        let (tex_y, tex_uv, _bind) = match self.images.get(&handle) {
2301            Some(ImageTex::Nv12 {
2302                tex_y,
2303                tex_uv,
2304                bind,
2305                ..
2306            }) => (tex_y, tex_uv, bind),
2307            _ => return Err(anyhow::anyhow!("Handle is not P010/NV12")),
2308        };
2309
2310        self.queue.write_texture(
2311            wgpu::TexelCopyTextureInfo {
2312                texture: tex_y,
2313                mip_level: 0,
2314                origin: wgpu::Origin3d::ZERO,
2315                aspect: wgpu::TextureAspect::All,
2316            },
2317            &y[..y_expected],
2318            wgpu::TexelCopyBufferLayout {
2319                offset: 0,
2320                bytes_per_row: Some(w * 2),
2321                rows_per_image: Some(h),
2322            },
2323            wgpu::Extent3d {
2324                width: w,
2325                height: h,
2326                depth_or_array_layers: 1,
2327            },
2328        );
2329        self.queue.write_texture(
2330            wgpu::TexelCopyTextureInfo {
2331                texture: tex_uv,
2332                mip_level: 0,
2333                origin: wgpu::Origin3d::ZERO,
2334                aspect: wgpu::TextureAspect::All,
2335            },
2336            &uv[..uv_expected],
2337            wgpu::TexelCopyBufferLayout {
2338                offset: 0,
2339                bytes_per_row: Some(uv_w * 4),
2340                rows_per_image: Some(uv_h),
2341            },
2342            wgpu::Extent3d {
2343                width: uv_w,
2344                height: uv_h,
2345                depth_or_array_layers: 1,
2346            },
2347        );
2348
2349        self.evict_budget_excess();
2350        Ok(())
2351    }
2352
2353    #[cfg(target_os = "linux")]
2354    pub fn set_image_dmabuf(
2355        &mut self,
2356        handle: u64,
2357        w: u32,
2358        h: u32,
2359        fds: Vec<std::os::unix::io::OwnedFd>,
2360        modifier: u64,
2361        strides: Vec<u32>,
2362        offsets: Vec<u64>,
2363        color_info: ColorInfo,
2364    ) -> anyhow::Result<()> {
2365        log::info!("set_image_dmabuf handle={handle} {}x{} fds={} modifier=0x{modifier:x}", w, h, fds.len());
2366
2367        self.remove_image(handle);
2368
2369        let yuv = color_info.to_yuv_transform();
2370        let yuv_raw = YuvTransformRaw {
2371            row0: [yuv.m[0][0], yuv.m[0][1], yuv.m[0][2], 0.0],
2372            row1: [yuv.m[1][0], yuv.m[1][1], yuv.m[1][2], 0.0],
2373            row2: [yuv.m[2][0], yuv.m[2][1], yuv.m[2][2], 0.0],
2374            b: [yuv.b[0], yuv.b[1], yuv.b[2], 0.0],
2375        };
2376
2377        if fds.len() != 2 {
2378            return Err(anyhow::anyhow!(
2379                "unsupported fd count {} - need exactly 2 for separate Y/UV planes",
2380                fds.len()
2381            ));
2382        }
2383
2384        let uv_w = w.div_ceil(2);
2385        let uv_h = h.div_ceil(2);
2386
2387        let hal_y_desc = wgpu::hal::TextureDescriptor {
2388            label: Some("dmabuf y"),
2389            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
2390            mip_level_count: 1,
2391            sample_count: 1,
2392            dimension: wgpu::TextureDimension::D2,
2393            format: wgpu::TextureFormat::R8Unorm,
2394            usage: wgpu::wgt::TextureUses::RESOURCE,
2395            memory_flags: wgpu::hal::MemoryFlags::empty(),
2396            view_formats: vec![],
2397        };
2398        let hal_uv_desc = wgpu::hal::TextureDescriptor {
2399            label: Some("dmabuf uv"),
2400            size: wgpu::Extent3d { width: uv_w, height: uv_h, depth_or_array_layers: 1 },
2401            mip_level_count: 1,
2402            sample_count: 1,
2403            dimension: wgpu::TextureDimension::D2,
2404            format: wgpu::TextureFormat::Rg8Unorm,
2405            usage: wgpu::wgt::TextureUses::RESOURCE,
2406            memory_flags: wgpu::hal::MemoryFlags::empty(),
2407            view_formats: vec![],
2408        };
2409
2410        let wgpu_y_desc = wgpu::TextureDescriptor {
2411            label: Some("dmabuf y"),
2412            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
2413            mip_level_count: 1,
2414            sample_count: 1,
2415            dimension: wgpu::TextureDimension::D2,
2416            format: wgpu::TextureFormat::R8Unorm,
2417            usage: wgpu::TextureUsages::TEXTURE_BINDING,
2418            view_formats: &[],
2419        };
2420        let wgpu_uv_desc = wgpu::TextureDescriptor {
2421            label: Some("dmabuf uv"),
2422            size: wgpu::Extent3d { width: uv_w, height: uv_h, depth_or_array_layers: 1 },
2423            mip_level_count: 1,
2424            sample_count: 1,
2425            dimension: wgpu::TextureDimension::D2,
2426            format: wgpu::TextureFormat::Rg8Unorm,
2427            usage: wgpu::TextureUsages::TEXTURE_BINDING,
2428            view_formats: &[],
2429        };
2430
2431        let (tex_y, view_y, tex_uv, view_uv) = unsafe {
2432            let mut hal_guard = self.device.as_hal::<wgpu::hal::vulkan::Api>()
2433                .ok_or_else(|| {
2434                    log::warn!("as_hal::<vulkan::Api> returned None");
2435                    anyhow::anyhow!("Device is not Vulkan")
2436                })?;
2437
2438            let mut fds = fds;
2439            let uv_fd = fds.remove(1);
2440            let y_fd = fds.remove(0);
2441
2442            let yt = hal_guard
2443                .texture_from_dmabuf_fd(y_fd, &hal_y_desc, modifier, strides[0] as u64, offsets[0] as u64)
2444                .map_err(|e| anyhow::anyhow!("import Y dmabuf: {e:?}"))?;
2445            log::info!("imported Y dmabuf OK");
2446
2447            let uvt = hal_guard
2448                .texture_from_dmabuf_fd(uv_fd, &hal_uv_desc, modifier, strides[1] as u64, offsets[1] as u64)
2449                .map_err(|e| anyhow::anyhow!("import UV dmabuf: {e:?}"))?;
2450            log::info!("imported UV dmabuf OK");
2451
2452            drop(hal_guard);
2453
2454            let tex_y = self.device.create_texture_from_hal::<wgpu::hal::vulkan::Api>(
2455                yt,
2456                &wgpu_y_desc,
2457                wgpu::wgt::TextureUses::UNINITIALIZED,
2458            );
2459            let view_y = tex_y.create_view(&wgpu::TextureViewDescriptor::default());
2460
2461            let tex_uv = self.device.create_texture_from_hal::<wgpu::hal::vulkan::Api>(
2462                uvt,
2463                &wgpu_uv_desc,
2464                wgpu::wgt::TextureUses::UNINITIALIZED,
2465            );
2466            let view_uv = tex_uv.create_view(&wgpu::TextureViewDescriptor::default());
2467
2468            (tex_y, view_y, tex_uv, view_uv)
2469        };
2470
2471        let yuv_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2472            label: Some("dmabuf yuv transform"),
2473            size: std::mem::size_of::<YuvTransformRaw>() as u64,
2474            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2475            mapped_at_creation: false,
2476        });
2477        self.queue.write_buffer(&yuv_buf, 0, bytemuck::bytes_of(&yuv_raw));
2478
2479        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2480            label: Some("dmabuf nv12 bind"),
2481            layout: &self.image_bind_layout_nv12,
2482            entries: &[
2483                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view_y) },
2484                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view_uv) },
2485                wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.image_sampler) },
2486                wgpu::BindGroupEntry {
2487                    binding: 3,
2488                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2489                        buffer: &yuv_buf,
2490                        offset: 0,
2491                        size: None,
2492                    }),
2493                },
2494            ],
2495        });
2496
2497        let bytes = (w as u64) * (h as u64)
2498            + (uv_w as u64) * (uv_h as u64) * 2
2499            + std::mem::size_of::<YuvTransformRaw>() as u64;
2500
2501        self.images.insert(
2502            handle,
2503            ImageTex::Nv12 {
2504                tex_y,
2505                view_y,
2506                tex_uv,
2507                view_uv,
2508                bind,
2509                yuv_buf,
2510                w,
2511                h,
2512                color_info,
2513                last_used_frame: self.frame_index,
2514                bytes,
2515            },
2516        );
2517
2518        self.evict_budget_excess();
2519        Ok(())
2520    }
2521
2522    pub fn remove_image(&mut self, handle: u64) {
2523        if let Some(img) = self.images.remove(&handle) {
2524            let b = match &img {
2525                ImageTex::Rgba { bytes, .. } => *bytes,
2526                ImageTex::Nv12 { bytes, .. } => *bytes,
2527            };
2528            self.image_bytes_total = self.image_bytes_total.saturating_sub(b);
2529        }
2530    }
2531
2532    // Legacy support from Step 1 instructions (temporary until platform render logic is fully swapped)
2533    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
2534        let handle = self.next_image_handle;
2535        self.next_image_handle += 1;
2536        if let Err(e) = self.set_image_from_bytes(handle, data, srgb) {
2537            log::error!("Failed to register image: {e}");
2538        }
2539        handle
2540    }
2541
2542    fn evict_unused_images(&mut self) {
2543        let now = self.frame_index;
2544        let evict_after = self.image_evict_after_frames;
2545
2546        // Time based eviction
2547        let mut to_remove = Vec::new();
2548        for (h, t) in self.images.iter() {
2549            let last = match t {
2550                ImageTex::Rgba {
2551                    last_used_frame, ..
2552                } => *last_used_frame,
2553                ImageTex::Nv12 {
2554                    last_used_frame, ..
2555                } => *last_used_frame,
2556            };
2557            if now.saturating_sub(last) > evict_after {
2558                to_remove.push(*h);
2559            }
2560        }
2561        for h in to_remove {
2562            self.remove_image(h);
2563        }
2564
2565        self.evict_budget_excess();
2566    }
2567
2568    fn evict_budget_excess(&mut self) {
2569        if self.image_bytes_total <= self.image_budget_bytes {
2570            return;
2571        }
2572        // Collect (handle, last_used, bytes)
2573        let mut candidates: Vec<(u64, u64, u64)> = self
2574            .images
2575            .iter()
2576            .map(|(h, t)| {
2577                let (last, bytes) = match t {
2578                    ImageTex::Rgba {
2579                        last_used_frame,
2580                        bytes,
2581                        ..
2582                    } => (*last_used_frame, *bytes),
2583                    ImageTex::Nv12 {
2584                        last_used_frame,
2585                        bytes,
2586                        ..
2587                    } => (*last_used_frame, *bytes),
2588                };
2589                (*h, last, bytes)
2590            })
2591            .collect();
2592
2593        // Sort by last_used ascending (LRU first)
2594        candidates.sort_by_key(|k| k.1);
2595
2596        let now = self.frame_index;
2597        for (h, last, _bytes) in candidates {
2598            if self.image_bytes_total <= self.image_budget_bytes {
2599                break;
2600            }
2601            // Don't evict something used this frame
2602            if last == now {
2603                continue;
2604            }
2605            self.remove_image(h);
2606        }
2607    }
2608
2609    /// Enable or disable linear working-space rendering.
2610    /// When enabled, the scene is rendered into an Rgba16Float intermediate
2611    /// and a final full-screen pass applies the display OETF.
2612    pub fn set_working_space(&mut self, enabled: bool) {
2613        if enabled == self.working_space {
2614            return;
2615        }
2616        self.working_space = enabled;
2617        if enabled {
2618            self.ensure_display_pipeline();
2619            self.recreate_working_space_texture();
2620        } else {
2621            self.ws_tex = None;
2622            self.ws_view = None;
2623            self.ws_bind = None;
2624        }
2625    }
2626
2627    fn ensure_display_pipeline(&mut self) {
2628        if self.display_pipeline.is_some() {
2629            return;
2630        }
2631
2632        let layout = self
2633            .device
2634            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2635                label: Some("display transform layout"),
2636                entries: &[
2637                    wgpu::BindGroupLayoutEntry {
2638                        binding: 0,
2639                        visibility: wgpu::ShaderStages::FRAGMENT,
2640                        ty: wgpu::BindingType::Texture {
2641                            multisampled: false,
2642                            view_dimension: wgpu::TextureViewDimension::D2,
2643                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
2644                        },
2645                        count: None,
2646                    },
2647                    wgpu::BindGroupLayoutEntry {
2648                        binding: 1,
2649                        visibility: wgpu::ShaderStages::FRAGMENT,
2650                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2651                        count: None,
2652                    },
2653                ],
2654            });
2655        self.display_layout = Some(layout);
2656
2657        let shader = self
2658            .device
2659            .create_shader_module(wgpu::ShaderModuleDescriptor {
2660                label: Some("display_transform.wgsl"),
2661                source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
2662                    "shaders/display_transform.wgsl"
2663                ))),
2664            });
2665
2666        let pipeline_layout = self
2667            .device
2668            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2669                label: Some("display transform pipeline layout"),
2670                bind_group_layouts: &[None, self.display_layout.as_ref()],
2671                immediate_size: 0,
2672            });
2673
2674        let pipeline = self
2675            .device
2676            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2677                label: Some("display transform pipeline"),
2678                layout: Some(&pipeline_layout),
2679                vertex: wgpu::VertexState {
2680                    module: &shader,
2681                    entry_point: Some("vs_main"),
2682                    buffers: &[],
2683                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2684                },
2685                fragment: Some(wgpu::FragmentState {
2686                    module: &shader,
2687                    entry_point: Some("fs_main"),
2688                    targets: &[Some(wgpu::ColorTargetState {
2689                        format: self.output_format,
2690                        blend: None,
2691                        write_mask: wgpu::ColorWrites::ALL,
2692                    })],
2693                    compilation_options: wgpu::PipelineCompilationOptions::default(),
2694                }),
2695                primitive: wgpu::PrimitiveState::default(),
2696                depth_stencil: None,
2697                multisample: wgpu::MultisampleState::default(),
2698                multiview_mask: None,
2699                cache: None,
2700            });
2701        self.display_pipeline = Some(pipeline);
2702    }
2703
2704    /// Resize the render target dimensions.
2705    ///
2706    /// Recreates MSAA, depth-stencil, and working-space textures to match the
2707    /// new size..
2708    pub fn resize(&mut self, width: u32, height: u32) {
2709        self.output_width = width;
2710        self.output_height = height;
2711        self.recreate_msaa_and_depth_stencil();
2712        self.recreate_working_space_texture();
2713    }
2714
2715    fn recreate_working_space_texture(&mut self) {
2716        if !self.working_space {
2717            return;
2718        }
2719        let w = self.output_width.max(1);
2720        let h = self.output_height.max(1);
2721
2722        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
2723            label: Some("working space"),
2724            size: wgpu::Extent3d {
2725                width: w,
2726                height: h,
2727                depth_or_array_layers: 1,
2728            },
2729            mip_level_count: 1,
2730            sample_count: 1,
2731            dimension: wgpu::TextureDimension::D2,
2732            format: wgpu::TextureFormat::Rgba16Float,
2733            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
2734            view_formats: &[],
2735        });
2736        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
2737
2738        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2739            label: Some("working space bind"),
2740            layout: self.display_layout.as_ref().unwrap(),
2741            entries: &[
2742                wgpu::BindGroupEntry {
2743                    binding: 0,
2744                    resource: wgpu::BindingResource::TextureView(&view),
2745                },
2746                wgpu::BindGroupEntry {
2747                    binding: 1,
2748                    resource: wgpu::BindingResource::Sampler(&self.image_sampler),
2749                },
2750            ],
2751        });
2752
2753        self.ws_tex = Some(tex);
2754        self.ws_view = Some(view);
2755        self.ws_bind = Some(bind);
2756    }
2757
2758    fn recreate_msaa_and_depth_stencil(&mut self) {
2759        if self.msaa_samples > 1 {
2760            let tex = self.device.create_texture(&wgpu::TextureDescriptor {
2761                label: Some("msaa color"),
2762                size: wgpu::Extent3d {
2763                    width: self.output_width.max(1),
2764                    height: self.output_height.max(1),
2765                    depth_or_array_layers: 1,
2766                },
2767                mip_level_count: 1,
2768                sample_count: self.msaa_samples,
2769                dimension: wgpu::TextureDimension::D2,
2770                format: self.output_format,
2771                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2772                view_formats: &[],
2773            });
2774            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
2775            self.msaa_tex = Some(tex);
2776            self.msaa_view = Some(view);
2777        } else {
2778            self.msaa_tex = None;
2779            self.msaa_view = None;
2780        }
2781
2782        self.depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
2783            label: Some("depth-stencil (stencil clips)"),
2784            size: wgpu::Extent3d {
2785                width: self.output_width.max(1),
2786                height: self.output_height.max(1),
2787                depth_or_array_layers: 1,
2788            },
2789            mip_level_count: 1,
2790            sample_count: self.msaa_samples,
2791            dimension: wgpu::TextureDimension::D2,
2792            format: wgpu::TextureFormat::Depth24PlusStencil8,
2793            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2794            view_formats: &[],
2795        });
2796        self.depth_stencil_view = self
2797            .depth_stencil_tex
2798            .create_view(&wgpu::TextureViewDescriptor::default());
2799    }
2800
2801
2802
2803    fn get_or_create_layer(
2804        &mut self,
2805        layer_id: u32,
2806        width: u32,
2807        height: u32,
2808        rect: repose_core::Rect,
2809    ) {
2810        let needs_alloc = match self.layer_pool.get(&layer_id) {
2811            Some(lt) => lt.width != width || lt.height != height,
2812            None => true,
2813        };
2814        if !needs_alloc {
2815            if let Some(lt) = self.layer_pool.get_mut(&layer_id) {
2816                lt.rect_px = (rect.x, rect.y, rect.w, rect.h);
2817            }
2818            return;
2819        }
2820        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
2821            label: Some("graphics layer"),
2822            size: wgpu::Extent3d {
2823                width: width.max(1),
2824                height: height.max(1),
2825                depth_or_array_layers: 1,
2826            },
2827            mip_level_count: 1,
2828            sample_count: 1,
2829            dimension: wgpu::TextureDimension::D2,
2830            format: self.output_format,
2831            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
2832            view_formats: &[],
2833        });
2834        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
2835        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2836            label: Some("layer bind"),
2837            layout: &self.image_bind_layout_rgba,
2838            entries: &[
2839                wgpu::BindGroupEntry {
2840                    binding: 0,
2841                    resource: wgpu::BindingResource::TextureView(&view),
2842                },
2843                wgpu::BindGroupEntry {
2844                    binding: 1,
2845                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler),
2846                },
2847            ],
2848        });
2849        let bind_linear = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2850            label: Some("layer bind linear"),
2851            layout: &self.image_bind_layout_rgba,
2852            entries: &[
2853                wgpu::BindGroupEntry {
2854                    binding: 0,
2855                    resource: wgpu::BindingResource::TextureView(&view),
2856                },
2857                wgpu::BindGroupEntry {
2858                    binding: 1,
2859                    resource: wgpu::BindingResource::Sampler(&self.layer_sampler_linear),
2860                },
2861            ],
2862        });
2863        let depth_stencil_tex = self.device.create_texture(&wgpu::TextureDescriptor {
2864            label: Some("graphics layer depth-stencil"),
2865            size: wgpu::Extent3d {
2866                width: width.max(1),
2867                height: height.max(1),
2868                depth_or_array_layers: 1,
2869            },
2870            mip_level_count: 1,
2871            sample_count: 1,
2872            dimension: wgpu::TextureDimension::D2,
2873            format: wgpu::TextureFormat::Depth24PlusStencil8,
2874            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2875            view_formats: &[],
2876        });
2877        let depth_stencil_view =
2878            depth_stencil_tex.create_view(&wgpu::TextureViewDescriptor::default());
2879        self.layer_pool.insert(
2880            layer_id,
2881            LayerTarget {
2882                texture: tex,
2883                view,
2884                bind,
2885                bind_linear,
2886                depth_stencil_tex,
2887                depth_stencil_view,
2888                width,
2889                height,
2890                rect_px: (rect.x, rect.y, rect.w, rect.h),
2891            },
2892        );
2893    }
2894
2895    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
2896        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2897            label: Some("atlas bind"),
2898            layout: &self.text_bind_layout,
2899            entries: &[
2900                wgpu::BindGroupEntry {
2901                    binding: 0,
2902                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
2903                },
2904                wgpu::BindGroupEntry {
2905                    binding: 1,
2906                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
2907                },
2908            ],
2909        })
2910    }
2911
2912    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
2913        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2914            label: Some("atlas bind color"),
2915            layout: &self.text_bind_layout,
2916            entries: &[
2917                wgpu::BindGroupEntry {
2918                    binding: 0,
2919                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
2920                },
2921                wgpu::BindGroupEntry {
2922                    binding: 1,
2923                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
2924                },
2925            ],
2926        })
2927    }
2928
2929    fn upload_glyph_mask(&mut self, key: repose_text::GlyphKey, px: f32) -> Option<GlyphInfo> {
2930        let keyp = (key, px.to_bits());
2931        if let Some(info) = self.atlas_mask.map.get(&keyp) {
2932            return Some(*info);
2933        }
2934
2935        let gb = repose_text::rasterize(key, px)?;
2936        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
2937            return None;
2938        }
2939
2940        let coverage = swash_to_a8_coverage(gb.content, &gb.data)?;
2941
2942        let w = gb.w.max(1);
2943        let h = gb.h.max(1);
2944
2945        if !self.alloc_space_mask(w, h) {
2946            self.grow_mask_and_rebuild();
2947        }
2948        if !self.alloc_space_mask(w, h) {
2949            return None;
2950        }
2951        let x = self.atlas_mask.next_x;
2952        let y = self.atlas_mask.next_y;
2953        self.atlas_mask.next_x += w + 1;
2954        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
2955
2956        let layout = wgpu::TexelCopyBufferLayout {
2957            offset: 0,
2958            bytes_per_row: Some(w),
2959            rows_per_image: Some(h),
2960        };
2961        let size = wgpu::Extent3d {
2962            width: w,
2963            height: h,
2964            depth_or_array_layers: 1,
2965        };
2966        self.queue.write_texture(
2967            wgpu::TexelCopyTextureInfoBase {
2968                texture: &self.atlas_mask.tex,
2969                mip_level: 0,
2970                origin: wgpu::Origin3d { x, y, z: 0 },
2971                aspect: wgpu::TextureAspect::All,
2972            },
2973            &coverage,
2974            layout,
2975            size,
2976        );
2977
2978        let info = GlyphInfo {
2979            u0: x as f32 / self.atlas_mask.size as f32,
2980            v0: y as f32 / self.atlas_mask.size as f32,
2981            u1: (x + w) as f32 / self.atlas_mask.size as f32,
2982            v1: (y + h) as f32 / self.atlas_mask.size as f32,
2983            w: w as f32,
2984            h: h as f32,
2985            bearing_x: 0.0,
2986            bearing_y: 0.0,
2987            advance: 0.0,
2988        };
2989        self.atlas_mask.map.insert(keyp, info);
2990        Some(info)
2991    }
2992
2993    fn upload_glyph_color(&mut self, key: repose_text::GlyphKey, px: f32) -> Option<GlyphInfo> {
2994        let keyp = (key, px.to_bits());
2995        if let Some(info) = self.atlas_color.map.get(&keyp) {
2996            return Some(*info);
2997        }
2998        let gb = repose_text::rasterize(key, px)?;
2999        if !matches!(gb.content, repose_text::SwashContent::Color) {
3000            return None;
3001        }
3002        let w = gb.w.max(1);
3003        let h = gb.h.max(1);
3004        if !self.alloc_space_color(w, h) {
3005            self.grow_color_and_rebuild();
3006        }
3007        if !self.alloc_space_color(w, h) {
3008            return None;
3009        }
3010        let x = self.atlas_color.next_x;
3011        let y = self.atlas_color.next_y;
3012        self.atlas_color.next_x += w + 1;
3013        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
3014
3015        let layout = wgpu::TexelCopyBufferLayout {
3016            offset: 0,
3017            bytes_per_row: Some(w * 4),
3018            rows_per_image: Some(h),
3019        };
3020        let size = wgpu::Extent3d {
3021            width: w,
3022            height: h,
3023            depth_or_array_layers: 1,
3024        };
3025        self.queue.write_texture(
3026            wgpu::TexelCopyTextureInfoBase {
3027                texture: &self.atlas_color.tex,
3028                mip_level: 0,
3029                origin: wgpu::Origin3d { x, y, z: 0 },
3030                aspect: wgpu::TextureAspect::All,
3031            },
3032            &gb.data,
3033            layout,
3034            size,
3035        );
3036        let info = GlyphInfo {
3037            u0: x as f32 / self.atlas_color.size as f32,
3038            v0: y as f32 / self.atlas_color.size as f32,
3039            u1: (x + w) as f32 / self.atlas_color.size as f32,
3040            v1: (y + h) as f32 / self.atlas_color.size as f32,
3041            w: w as f32,
3042            h: h as f32,
3043            bearing_x: 0.0,
3044            bearing_y: 0.0,
3045            advance: 0.0,
3046        };
3047        self.atlas_color.map.insert(keyp, info);
3048        Some(info)
3049    }
3050
3051    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
3052        if self.atlas_mask.next_x + w + 1 >= self.atlas_mask.size {
3053            self.atlas_mask.next_x = 1;
3054            self.atlas_mask.next_y += self.atlas_mask.row_h + 1;
3055            self.atlas_mask.row_h = 0;
3056        }
3057        if self.atlas_mask.next_y + h + 1 >= self.atlas_mask.size {
3058            return false;
3059        }
3060        true
3061    }
3062
3063    fn grow_mask_and_rebuild(&mut self) {
3064        let new_size = (self.atlas_mask.size * 2).min(4096);
3065        if new_size == self.atlas_mask.size {
3066            return;
3067        }
3068        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
3069            label: Some("glyph atlas A8 (grown)"),
3070            size: wgpu::Extent3d {
3071                width: new_size,
3072                height: new_size,
3073                depth_or_array_layers: 1,
3074            },
3075            mip_level_count: 1,
3076            sample_count: 1,
3077            dimension: wgpu::TextureDimension::D2,
3078            format: wgpu::TextureFormat::R8Unorm,
3079            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3080            view_formats: &[],
3081        });
3082        self.atlas_mask.tex = tex;
3083        self.atlas_mask.view = self
3084            .atlas_mask
3085            .tex
3086            .create_view(&wgpu::TextureViewDescriptor::default());
3087        self.atlas_mask.size = new_size;
3088        self.atlas_mask.next_x = 1;
3089        self.atlas_mask.next_y = 1;
3090        self.atlas_mask.row_h = 0;
3091        let keys: Vec<(repose_text::GlyphKey, u32)> = self.atlas_mask.map.keys().copied().collect();
3092        self.atlas_mask.map.clear();
3093        for (k, px_bits) in keys {
3094            let _ = self.upload_glyph_mask(k, f32::from_bits(px_bits));
3095        }
3096    }
3097
3098    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
3099        if self.atlas_color.next_x + w + 1 >= self.atlas_color.size {
3100            self.atlas_color.next_x = 1;
3101            self.atlas_color.next_y += self.atlas_color.row_h + 1;
3102            self.atlas_color.row_h = 0;
3103        }
3104        if self.atlas_color.next_y + h + 1 >= self.atlas_color.size {
3105            return false;
3106        }
3107        true
3108    }
3109
3110    fn grow_color_and_rebuild(&mut self) {
3111        let new_size = (self.atlas_color.size * 2).min(4096);
3112        if new_size == self.atlas_color.size {
3113            return;
3114        }
3115        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
3116            label: Some("glyph atlas RGBA (grown)"),
3117            size: wgpu::Extent3d {
3118                width: new_size,
3119                height: new_size,
3120                depth_or_array_layers: 1,
3121            },
3122            mip_level_count: 1,
3123            sample_count: 1,
3124            dimension: wgpu::TextureDimension::D2,
3125            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3126            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3127            view_formats: &[],
3128        });
3129        self.atlas_color.tex = tex;
3130        self.atlas_color.view = self
3131            .atlas_color
3132            .tex
3133            .create_view(&wgpu::TextureViewDescriptor::default());
3134        self.atlas_color.size = new_size;
3135        self.atlas_color.next_x = 1;
3136        self.atlas_color.next_y = 1;
3137        self.atlas_color.row_h = 0;
3138        let keys: Vec<(repose_text::GlyphKey, u32)> =
3139            self.atlas_color.map.keys().copied().collect();
3140        self.atlas_color.map.clear();
3141        for (k, px_bits) in keys {
3142            let _ = self.upload_glyph_color(k, f32::from_bits(px_bits));
3143        }
3144    }
3145}
3146
3147fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
3148    match brush {
3149        Brush::Solid(c) => (
3150            0u32,
3151            c.to_linear(),
3152            [0.0, 0.0, 0.0, 0.0],
3153            [0.0, 0.0],
3154            [0.0, 1.0],
3155        ),
3156        Brush::Linear {
3157            start,
3158            end,
3159            start_color,
3160            end_color,
3161        } => (
3162            1u32,
3163            start_color.to_linear(),
3164            end_color.to_linear(),
3165            [start.x, start.y],
3166            [end.x, end.y],
3167        ),
3168        _ => (0u32, [0.0; 4], [0.0; 4], [0.0; 2], [0.0; 2]),
3169    }
3170}
3171
3172fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
3173    match brush {
3174        Brush::Solid(c) => c.to_linear(),
3175        Brush::Linear { start_color, .. } => start_color.to_linear(),
3176        _ => [0.0; 4],
3177    }
3178}
3179
3180fn init_atlas_mask(device: &wgpu::Device) -> AtlasA8 {
3181    let size = 1024u32;
3182    let tex = device.create_texture(&wgpu::TextureDescriptor {
3183        label: Some("glyph atlas A8"),
3184        size: wgpu::Extent3d {
3185            width: size,
3186            height: size,
3187            depth_or_array_layers: 1,
3188        },
3189        mip_level_count: 1,
3190        sample_count: 1,
3191        dimension: wgpu::TextureDimension::D2,
3192        format: wgpu::TextureFormat::R8Unorm,
3193        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3194        view_formats: &[],
3195    });
3196    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3197    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3198        label: Some("glyph atlas sampler A8"),
3199        address_mode_u: wgpu::AddressMode::ClampToEdge,
3200        address_mode_v: wgpu::AddressMode::ClampToEdge,
3201        address_mode_w: wgpu::AddressMode::ClampToEdge,
3202        mag_filter: wgpu::FilterMode::Linear,
3203        min_filter: wgpu::FilterMode::Linear,
3204        mipmap_filter: wgpu::MipmapFilterMode::Linear,
3205        ..Default::default()
3206    });
3207
3208    AtlasA8 {
3209        tex,
3210        view,
3211        sampler,
3212        size,
3213        next_x: 1,
3214        next_y: 1,
3215        row_h: 0,
3216        map: HashMap::new(),
3217    }
3218}
3219
3220fn init_atlas_color(device: &wgpu::Device) -> AtlasRGBA {
3221    let size = 1024u32;
3222    let tex = device.create_texture(&wgpu::TextureDescriptor {
3223        label: Some("glyph atlas RGBA"),
3224        size: wgpu::Extent3d {
3225            width: size,
3226            height: size,
3227            depth_or_array_layers: 1,
3228        },
3229        mip_level_count: 1,
3230        sample_count: 1,
3231        dimension: wgpu::TextureDimension::D2,
3232        format: wgpu::TextureFormat::Rgba8UnormSrgb,
3233        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3234        view_formats: &[],
3235    });
3236    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
3237    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
3238        label: Some("glyph atlas sampler RGBA"),
3239        address_mode_u: wgpu::AddressMode::ClampToEdge,
3240        address_mode_v: wgpu::AddressMode::ClampToEdge,
3241        address_mode_w: wgpu::AddressMode::ClampToEdge,
3242        mag_filter: wgpu::FilterMode::Linear,
3243        min_filter: wgpu::FilterMode::Linear,
3244        mipmap_filter: wgpu::MipmapFilterMode::Linear,
3245        ..Default::default()
3246    });
3247    AtlasRGBA {
3248        tex,
3249        view,
3250        sampler,
3251        size,
3252        next_x: 1,
3253        next_y: 1,
3254        row_h: 0,
3255        map: HashMap::new(),
3256    }
3257}
3258
3259#[cfg(feature = "winit-surface")]
3260impl RenderBackend for WgpuSurfaceBackend {
3261    fn configure_surface(&mut self, width: u32, height: u32) {
3262        if width == 0 || height == 0 {
3263            return;
3264        }
3265        self.renderer.output_width = width;
3266        self.renderer.output_height = height;
3267        if let Some(ref mut config) = self.surface_config {
3268            config.width = width;
3269            config.height = height;
3270        }
3271        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.surface_config.as_ref()) {
3272            surface.configure(&self.renderer.device, config);
3273        }
3274        self.renderer.recreate_msaa_and_depth_stencil();
3275        self.renderer.recreate_working_space_texture();
3276    }
3277
3278    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) {
3279        let surface = self.surface.as_ref().expect("WgpuSurfaceBackend::frame() requires a surface (use from_device + render_to_view instead)");
3280        let surface_config = self.surface_config.as_ref().expect("surface_config required for frame()");
3281
3282        self.renderer.frame_index = self.renderer.frame_index.wrapping_add(1);
3283        self.renderer.slug_cache.next_frame();
3284
3285        if self.renderer.output_width == 0 || self.renderer.output_height == 0 {
3286            return;
3287        }
3288
3289        let mut retries = 0u32;
3290        const MAX_RETRIES: u32 = 4;
3291        let frame = loop {
3292            match surface.get_current_texture() {
3293                wgpu::CurrentSurfaceTexture::Success(f) => break f,
3294                wgpu::CurrentSurfaceTexture::Suboptimal(f) => {
3295                    log::warn!("suboptimal surface; reconfiguring");
3296                    surface.configure(&self.renderer.device, surface_config);
3297                    break f;
3298                }
3299                wgpu::CurrentSurfaceTexture::Outdated => {
3300                    retries += 1;
3301                    if retries >= MAX_RETRIES {
3302                        log::warn!("surface outdated persisted after {MAX_RETRIES} retries; skipping frame");
3303                        return;
3304                    }
3305                    log::warn!("surface outdated; reconfiguring");
3306                    surface.configure(&self.renderer.device, surface_config);
3307                }
3308                wgpu::CurrentSurfaceTexture::Lost => {
3309                    retries += 1;
3310                    if retries >= MAX_RETRIES {
3311                        log::warn!("surface lost persisted after {MAX_RETRIES} retries; skipping frame");
3312                        return;
3313                    }
3314                    log::warn!("surface lost; reconfiguring");
3315                    surface.configure(&self.renderer.device, surface_config);
3316                }
3317                wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
3318                    request_frame();
3319                    return;
3320                }
3321                wgpu::CurrentSurfaceTexture::Validation => {
3322                    retries += 1;
3323                    if retries >= MAX_RETRIES {
3324                        log::warn!("surface validation persisted after {MAX_RETRIES} retries; skipping frame");
3325                        return;
3326                    }
3327                    surface.configure(&self.renderer.device, surface_config);
3328                }
3329            }
3330        };
3331
3332        let swap_view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default());
3333        let mut encoder = self.renderer.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
3334            label: Some("frame encoder"),
3335        });
3336
3337        let clear_color = Some([
3338            scene.clear_color.0 as f64 / 255.0,
3339            scene.clear_color.1 as f64 / 255.0,
3340            scene.clear_color.2 as f64 / 255.0,
3341            scene.clear_color.3 as f64 / 255.0,
3342        ]);
3343
3344        self.renderer.render_scene_to_encoder(scene, &mut encoder, &swap_view, clear_color);
3345
3346        self.renderer.queue.submit(std::iter::once(encoder.finish()));
3347        if let Err(e) = catch_unwind(AssertUnwindSafe(|| self.renderer.queue.present(frame))) {
3348            log::warn!("queue.present panicked: {:?}", e);
3349        }
3350    }
3351}
3352
3353impl WgpuSceneRenderer {
3354    pub fn render_scene_to_encoder(
3355        &mut self,
3356        scene: &Scene,
3357        encoder: &mut wgpu::CommandEncoder,
3358        target_view: &wgpu::TextureView,
3359        clear_color_override: Option<[f64; 4]>,
3360    ) {
3361        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
3362            let x0 = (x / fb_w) * 2.0 - 1.0;
3363            let y0 = 1.0 - (y / fb_h) * 2.0;
3364            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
3365            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
3366            let min_x = x0.min(x1);
3367            let min_y = y0.min(y1);
3368            let w_ndc = (x1 - x0).abs();
3369            let h_ndc = (y1 - y0).abs();
3370            [min_x, min_y, w_ndc, h_ndc]
3371        }
3372
3373        /// Convert a local-space rect + transform to NDC center-based position+size and rotation.
3374        fn rect_to_instance_ndc(
3375            rect: repose_core::Rect,
3376            transform: &Transform,
3377            fb_w: f32,
3378            fb_h: f32,
3379        ) -> ([f32; 4], [f32; 2]) {
3380            let cx = rect.x + rect.w * 0.5;
3381            let cy = rect.y + rect.h * 0.5;
3382
3383            // Apply full transform to center
3384            let sx = cx * transform.scale_x;
3385            let sy = cy * transform.scale_y;
3386            let cos_a = transform.rotate.cos();
3387            let sin_a = transform.rotate.sin();
3388            let tx = sx * cos_a - sy * sin_a + transform.translate_x;
3389            let ty = sx * sin_a + sy * cos_a + transform.translate_y;
3390
3391            // NDC center
3392            let ndc_cx = (tx / fb_w) * 2.0 - 1.0;
3393            let ndc_cy = 1.0 - (ty / fb_h) * 2.0;
3394            // NDC size (after scale only, no rotation - rotation is done in shader)
3395            let ndc_w = (rect.w * transform.scale_x / fb_w) * 2.0;
3396            let ndc_h = (rect.h * transform.scale_y / fb_h) * 2.0;
3397
3398            ([ndc_cx, ndc_cy, ndc_w, ndc_h], [cos_a, sin_a])
3399        }
3400
3401        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
3402            let mut x = r.x.floor() as i64;
3403            let mut y = r.y.floor() as i64;
3404            let fb_wi = fb_w as i64;
3405            let fb_hi = fb_h as i64;
3406            x = x.clamp(0, fb_wi.saturating_sub(1));
3407            y = y.clamp(0, fb_hi.saturating_sub(1));
3408            let w_req = r.w.ceil().max(1.0) as i64;
3409            let h_req = r.h.ceil().max(1.0) as i64;
3410            let w = (w_req).min(fb_wi - x).max(1);
3411            let h = (h_req).min(fb_hi - y).max(1);
3412            (x as u32, y as u32, w as u32, h as u32)
3413        }
3414
3415        let fb_w = self.output_width as f32;
3416        let fb_h = self.output_height as f32;
3417
3418        let mut passes: Vec<Pass> = Vec::with_capacity(1);
3419        let clear_color = clear_color_override.unwrap_or_else(|| {
3420            [
3421                scene.clear_color.0 as f64 / 255.0,
3422                scene.clear_color.1 as f64 / 255.0,
3423                scene.clear_color.2 as f64 / 255.0,
3424                scene.clear_color.3 as f64 / 255.0,
3425            ]
3426        });
3427        let mut current_pass: Pass = Pass {
3428            target: PassTarget::Surface,
3429            initial_scissor: (0, 0, self.output_width, self.output_height),
3430            clear_color: Some([
3431                clear_color[0] as f32,
3432                clear_color[1] as f32,
3433                clear_color[2] as f32,
3434                clear_color[3] as f32,
3435            ]),
3436            cmds: Vec::with_capacity(scene.nodes.len()),
3437        };
3438        let mut target_stack: Vec<PassTarget> = Vec::new();
3439        let mut layer_alphas: Vec<(u32, f32, (u32, u32, u32, u32))> = Vec::new();
3440        let mut layer_blurs: Vec<(u32, f32, f32)> = Vec::new();
3441        let mut current_target_size: (f32, f32) = (fb_w, fb_h);
3442
3443        struct Batch {
3444            rects: Vec<RectInstance>,
3445            borders: Vec<BorderInstance>,
3446            ellipses: Vec<EllipseInstance>,
3447            e_borders: Vec<EllipseBorderInstance>,
3448            arcs: Vec<ArcInstance>,
3449            masks: Vec<GlyphInstance>,
3450            colors: Vec<GlyphInstance>,
3451            nv12s: Vec<Nv12Instance>,
3452        }
3453
3454        impl Batch {
3455            fn new() -> Self {
3456                Self {
3457                    rects: vec![],
3458                    borders: vec![],
3459                    ellipses: vec![],
3460                    e_borders: vec![],
3461                    arcs: vec![],
3462                    masks: vec![],
3463                    colors: vec![],
3464                    nv12s: vec![],
3465                }
3466            }
3467
3468            fn is_empty(&self) -> bool {
3469                self.rects.is_empty()
3470                    && self.borders.is_empty()
3471                    && self.ellipses.is_empty()
3472                    && self.e_borders.is_empty()
3473                    && self.arcs.is_empty()
3474                    && self.masks.is_empty()
3475                    && self.colors.is_empty()
3476                    && self.nv12s.is_empty()
3477            }
3478
3479            fn flush(
3480                &mut self,
3481                pipes: (
3482                    &mut InstancedPipe<RectInstance>,
3483                    &mut InstancedPipe<BorderInstance>,
3484                    &mut InstancedPipe<EllipseInstance>,
3485                    &mut InstancedPipe<EllipseBorderInstance>,
3486                    &mut InstancedPipe<ArcInstance>,
3487                ),
3488                glyph_pipes: (
3489                    &mut InstancedPipe<GlyphInstance>,
3490                    &mut InstancedPipe<GlyphInstance>,
3491                ),
3492                nv12_pipe: &mut InstancedPipe<Nv12Instance>,
3493                device: &wgpu::Device,
3494                queue: &wgpu::Queue,
3495                cmds: &mut Vec<Cmd>,
3496            ) {
3497                let (rects, borders, ellipses, e_borders, arcs) = pipes;
3498                let (masks, colors) = glyph_pipes;
3499
3500                macro_rules! flush_one {
3501                    ($buf:ident, $pipe:expr, $variant:ident) => {
3502                        if !self.$buf.is_empty() {
3503                            if let Some((off, cnt)) = $pipe.upload(device, queue, &self.$buf) {
3504                                cmds.push(Cmd::$variant { off, cnt });
3505                            }
3506                            self.$buf.clear();
3507                        }
3508                    };
3509                }
3510
3511                flush_one!(rects, rects, Rect);
3512                flush_one!(borders, borders, Border);
3513                flush_one!(ellipses, ellipses, Ellipse);
3514                flush_one!(e_borders, e_borders, EllipseBorder);
3515                flush_one!(arcs, arcs, Arc);
3516                flush_one!(masks, masks, GlyphsMask);
3517                flush_one!(colors, colors, GlyphsColor);
3518
3519                if !self.nv12s.is_empty() {
3520                    if let Some((off, cnt)) = nv12_pipe.upload(device, queue, &self.nv12s) {
3521                        let _ = (off, cnt);
3522                    }
3523                    self.nv12s.clear();
3524                }
3525            }
3526        }
3527
3528        self.rects.reset();
3529        self.borders.reset();
3530        self.ellipses.reset();
3531        self.ellipse_borders.reset();
3532        self.arcs.reset();
3533        self.glyph_mask.reset();
3534        self.glyph_color.reset();
3535        self.clip_ring.reset();
3536        self.blur_ring.reset();
3537        self.nv12.reset();
3538
3539        self.slug_ring.reset();
3540        let mut batch = Batch::new();
3541        let mut slug_verts_local: Vec<slug::TessVertex> = Vec::new();
3542        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
3543        let mut scissor_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
3544        let root_clip_rect = repose_core::Rect {
3545            x: 0.0,
3546            y: 0.0,
3547            w: fb_w,
3548            h: fb_h,
3549        };
3550
3551        let mut current_prim: Option<&'static str> = None;
3552
3553        macro_rules! flush_if_prim_changed {
3554            ($prim:literal, $pipe:expr) => {
3555                if current_prim != Some($prim) {
3556                    flush_batch!();
3557                    current_prim = Some($prim);
3558                }
3559            };
3560        }
3561
3562        macro_rules! flush_batch {
3563            () => {
3564                if !batch.is_empty() {
3565                    batch.flush(
3566                        (
3567                            &mut self.rects,
3568                            &mut self.borders,
3569                            &mut self.ellipses,
3570                            &mut self.ellipse_borders,
3571                            &mut self.arcs,
3572                        ),
3573                        (&mut self.glyph_mask, &mut self.glyph_color),
3574                        &mut self.nv12,
3575                        &self.device,
3576                        &self.queue,
3577                        &mut current_pass.cmds,
3578                    )
3579                }
3580            };
3581        }
3582        for node in &scene.nodes {
3583            let t_identity = Transform::identity();
3584            let current_transform = transform_stack.last().unwrap_or(&t_identity);
3585
3586            match node {
3587                SceneNode::Rect {
3588                    rect,
3589                    brush,
3590                    radius,
3591                } => {
3592                    flush_if_prim_changed!("rect", &self.rects);
3593                    let (ndc, sin_cos) = rect_to_instance_ndc(
3594                        *rect,
3595                        current_transform,
3596                        current_target_size.0,
3597                        current_target_size.1,
3598                    );
3599                    let (brush_type, color0, color1, grad_start, grad_end) =
3600                        brush_to_instance_fields(brush);
3601                    batch.rects.push(RectInstance {
3602                        xywh: ndc,
3603                        radii: *radius,
3604                        brush_type,
3605                        _pad: [0.0; 3],
3606                        color0,
3607                        color1,
3608                        grad_start,
3609                        grad_end,
3610                        sin_cos,
3611                    });
3612                }
3613                SceneNode::Border {
3614                    rect,
3615                    color,
3616                    width,
3617                    radius,
3618                } => {
3619                    flush_if_prim_changed!("border", &self.borders);
3620                    let (ndc, sin_cos) = rect_to_instance_ndc(
3621                        *rect,
3622                        current_transform,
3623                        current_target_size.0,
3624                        current_target_size.1,
3625                    );
3626                    batch.borders.push(BorderInstance {
3627                        xywh: ndc,
3628                        radii: *radius,
3629                        stroke: *width,
3630                        color: color.to_linear(),
3631                        sin_cos,
3632                    });
3633                }
3634                SceneNode::Ellipse { rect, brush } => {
3635                    flush_if_prim_changed!("ellipse", &self.ellipses);
3636                    let (ndc, sin_cos) = rect_to_instance_ndc(
3637                        *rect,
3638                        current_transform,
3639                        current_target_size.0,
3640                        current_target_size.1,
3641                    );
3642                    let color = brush_to_solid_color(brush);
3643                    batch.ellipses.push(EllipseInstance {
3644                        xywh: ndc,
3645                        color,
3646                        sin_cos,
3647                    });
3648                }
3649                SceneNode::EllipseBorder { rect, color, width } => {
3650                    flush_if_prim_changed!("ellipse_border", &self.ellipse_borders);
3651                    let (ndc, sin_cos) = rect_to_instance_ndc(
3652                        *rect,
3653                        current_transform,
3654                        current_target_size.0,
3655                        current_target_size.1,
3656                    );
3657                    let pad_px = *width * 0.5 + 2.0;
3658                    let pad = (pad_px / current_target_size.0) * 2.0;
3659                    batch.e_borders.push(EllipseBorderInstance {
3660                        xywh: ndc,
3661                        stroke: *width,
3662                        pad,
3663                        color: color.to_linear(),
3664                        sin_cos,
3665                    });
3666                }
3667                SceneNode::Arc {
3668                    rect,
3669                    start_angle,
3670                    sweep_angle,
3671                    stroke_width,
3672                    color,
3673                    cap,
3674                } => {
3675                    flush_if_prim_changed!("arc", &self.arcs);
3676                    let (ndc, sin_cos) = rect_to_instance_ndc(
3677                        *rect,
3678                        current_transform,
3679                        current_target_size.0,
3680                        current_target_size.1,
3681                    );
3682                    let pad_px = *stroke_width * 0.5 + 2.0;
3683                    let pad = (pad_px / current_target_size.0) * 2.0;
3684                    let cap_val = match cap {
3685                        StrokeCap::Butt => 0.0,
3686                        StrokeCap::Round => 1.0,
3687                        StrokeCap::Square => 2.0,
3688                    };
3689                    batch.arcs.push(ArcInstance {
3690                        xywh: ndc,
3691                        start_angle: *start_angle,
3692                        sweep_angle: *sweep_angle,
3693                        stroke: *stroke_width,
3694                        pad,
3695                        color: color.to_linear(),
3696                        sin_cos,
3697                        cap: cap_val,
3698                    });
3699                }
3700                SceneNode::Text {
3701                    rect,
3702                    text,
3703                    color,
3704                    size,
3705                    font_family,
3706                    text_align: _,
3707                    font_weight,
3708                    font_style,
3709                    text_decoration,
3710                    letter_spacing,
3711                    line_height: _,
3712                    extra_style,
3713                    url: _,
3714                    font_variation_settings,
3715                } => {
3716                    flush_batch!(); // flush any prior primitives
3717
3718                    let px = *size;
3719                    let lh_ratio = rect.h / px;
3720                    let fw = font_weight.0;
3721                    let fs = if *font_style == FontStyle::Italic {
3722                        1
3723                    } else {
3724                        0
3725                    };
3726                    let shaped = repose_text::shape_line(
3727                        text.as_ref(),
3728                        px,
3729                        lh_ratio,
3730                        *font_family,
3731                        fw,
3732                        fs,
3733                        *letter_spacing,
3734                        font_variation_settings.as_deref(),
3735                    );
3736                    let baseline_y = shaped.first().map(|g| rect.y + g.y);
3737
3738                    let cos_a = current_transform.rotate.cos();
3739                    let sin_a = current_transform.rotate.sin();
3740                    let has_rotation = current_transform.rotate != 0.0;
3741
3742                    // For rotated text, the pivot is the center of the text rect.
3743                    let pivot_x = rect.x + rect.w * 0.5;
3744                    let pivot_y = rect.y + rect.h * 0.5;
3745
3746                    // Helper: compute NDC for a glyph rect, handling rotation correctly.
3747                    let make_glyph_instance =
3748                        |gx: f32, gy: f32, gw: f32, gh: f32| -> ([f32; 4], [f32; 2]) {
3749                            if has_rotation {
3750                                let corners =
3751                                    [(gx, gy), (gx + gw, gy), (gx + gw, gy + gh), (gx, gy + gh)];
3752                                let mut min_x = f32::MAX;
3753                                let mut max_x = f32::MIN;
3754                                let mut min_y = f32::MAX;
3755                                let mut max_y = f32::MIN;
3756                                for &(x, y) in &corners {
3757                                    let dx = x - pivot_x;
3758                                    let dy = y - pivot_y;
3759                                    let rx = pivot_x + dx * cos_a - dy * sin_a;
3760                                    let ry = pivot_y + dx * sin_a + dy * cos_a;
3761                                    min_x = min_x.min(rx);
3762                                    max_x = max_x.max(rx);
3763                                    min_y = min_y.min(ry);
3764                                    max_y = max_y.max(ry);
3765                                }
3766                                let bb_w = max_x - min_x;
3767                                let bb_h = max_y - min_y;
3768                                let ndc_tl = to_ndc(
3769                                    min_x,
3770                                    min_y,
3771                                    bb_w,
3772                                    bb_h,
3773                                    current_target_size.0,
3774                                    current_target_size.1,
3775                                );
3776                                let ndc = [
3777                                    ndc_tl[0] + ndc_tl[2] * 0.5,
3778                                    ndc_tl[1] + ndc_tl[3] * 0.5,
3779                                    ndc_tl[2],
3780                                    ndc_tl[3],
3781                                ];
3782                                (ndc, [cos_a, sin_a])
3783                            } else {
3784                                // Only safe at 1:1 scale (no zoom animations active).
3785                                let (sx, sy) = if current_transform.scale_x == 1.0
3786                                    && current_transform.scale_y == 1.0
3787                                {
3788                                    (gx.round(), gy.round())
3789                                } else {
3790                                    (gx, gy)
3791                                };
3792                                rect_to_instance_ndc(
3793                                    repose_core::Rect {
3794                                        x: sx,
3795                                        y: sy,
3796                                        w: gw,
3797                                        h: gh,
3798                                    },
3799                                    current_transform,
3800                                    current_target_size.0,
3801                                    current_target_size.1,
3802                                )
3803                            }
3804                        };
3805
3806                    let baseline_shift_y: f32 = px * extra_style.baseline_shift.0;
3807
3808                    let (
3809                        is_stroke,
3810                        stroke_width,
3811                        stroke_cap,
3812                        stroke_join,
3813                        stroke_miter,
3814                        stroke_path_effect,
3815                    ) = match &extra_style.draw_style {
3816                        repose_core::DrawStyle::Stroke {
3817                            width,
3818                            cap,
3819                            join,
3820                            miter,
3821                            path_effect,
3822                        } => (true, *width, *cap, *join, *miter, path_effect.clone()),
3823                        _ => (
3824                            false,
3825                            0.0,
3826                            repose_core::StrokeCap::Butt,
3827                            repose_core::StrokeJoin::Miter,
3828                            4.0,
3829                            None,
3830                        ),
3831                    };
3832                    let stroke_tess_key = if is_stroke {
3833                        Some(slug::StrokeTessKey::new(
3834                            stroke_width,
3835                            stroke_cap,
3836                            stroke_join,
3837                            stroke_miter,
3838                            &stroke_path_effect,
3839                        ))
3840                    } else {
3841                        None
3842                    };
3843
3844                    for sg in shaped {
3845                        let gx = rect.x + sg.x + sg.bearing_x;
3846                        let gy = rect.y + sg.y - sg.bearing_y + baseline_shift_y;
3847
3848                        // Vector glyph path: tessellated geometry with MSAA.
3849                        if self.slug_enabled {
3850                            let ck = repose_text::lookup_cache_key(sg.key, sg.px);
3851                            if let Some(ref ck) = ck {
3852                                // Check if cached.
3853                                let need_tessellate = self.slug_cache.get(ck).map_or(true, |g| {
3854                                    if is_stroke {
3855                                        let key = stroke_tess_key.as_ref().unwrap();
3856                                        !g.stroke_variants.contains_key(key)
3857                                    } else {
3858                                        g.fill_vertices.is_none()
3859                                    }
3860                                });
3861                                if need_tessellate {
3862                                    if let Some((ck2, commands)) =
3863                                        repose_text::lookup_and_extract_outline(sg.key, sg.px)
3864                                    {
3865                                        let font_size_px = f32::from_bits(ck2.font_size_bits);
3866                                        if is_stroke {
3867                                            self.slug_cache.get_or_insert_stroke(
3868                                                ck2,
3869                                                font_size_px,
3870                                                &commands,
3871                                                stroke_width,
3872                                                stroke_cap,
3873                                                stroke_join,
3874                                                stroke_miter,
3875                                                &stroke_path_effect,
3876                                            );
3877                                        } else {
3878                                            self.slug_cache.get_or_insert(
3879                                                ck2,
3880                                                font_size_px,
3881                                                &commands,
3882                                            );
3883                                        }
3884                                    }
3885                                } else {
3886                                    self.slug_cache.touch(ck);
3887                                }
3888                            }
3889                            if let Some(entry) = ck.as_ref().and_then(|ck| self.slug_cache.get(ck))
3890                            {
3891                                let ox = rect.x + sg.x;
3892                                let oy = rect.y + sg.y + baseline_shift_y;
3893                                let scx = current_transform.scale_x;
3894                                let scy = current_transform.scale_y;
3895                                let ttx = current_transform.translate_x;
3896                                let tty = current_transform.translate_y;
3897
3898                                let tf = |x: f32, y: f32| -> (f32, f32) {
3899                                    if has_rotation {
3900                                        let dx = x - pivot_x;
3901                                        let dy = y - pivot_y;
3902                                        let rx = pivot_x + dx * cos_a - dy * sin_a;
3903                                        let ry = pivot_y + dx * sin_a + dy * cos_a;
3904                                        (rx, ry)
3905                                    } else {
3906                                        (x * scx + ttx, y * scy + tty)
3907                                    }
3908                                };
3909
3910                                let tw = current_target_size.0;
3911                                let th = current_target_size.1;
3912
3913                                let verts = if is_stroke {
3914                                    let key = stroke_tess_key.as_ref().unwrap();
3915                                    entry
3916                                        .stroke_variants
3917                                        .get(key)
3918                                        .map(|v| v.as_slice())
3919                                        .unwrap_or(&[])
3920                                } else {
3921                                    entry.fill_vertices.as_deref().unwrap_or(&[])
3922                                };
3923
3924                                for &v in verts {
3925                                    let (sx, sy) = tf(ox + v[0] * px, oy - v[1] * px);
3926                                    let ndc_x = sx / tw * 2.0 - 1.0;
3927                                    let ndc_y = -(sy / th) * 2.0 + 1.0;
3928                                    slug_verts_local.push(slug::TessVertex {
3929                                        ndc_pos: [ndc_x, ndc_y],
3930                                        color: color.to_linear(),
3931                                    });
3932                                }
3933
3934                                if is_stroke {
3935                                    // Stroke glyphs cannot use atlas fallback...
3936                                    continue;
3937                                }
3938                                continue;
3939                            }
3940                        }
3941
3942                        // Don't use atlas fallback for strokes too
3943                        if is_stroke {
3944                            continue;
3945                        }
3946
3947                        // Atlas fallback: color emoji + failed slug extraction
3948                        if let Some(info) = self.upload_glyph_color(sg.key, sg.px) {
3949                            let (ndc, sin_cos) = make_glyph_instance(gx, gy, info.w, info.h);
3950                            batch.colors.push(GlyphInstance {
3951                                xywh: ndc,
3952                                uv: [info.u0, info.v1, info.u1, info.v0],
3953                                color: color.to_linear(),
3954                                sin_cos,
3955                            });
3956                        } else if let Some(info) = self.upload_glyph_mask(sg.key, sg.px) {
3957                            let (ndc, sin_cos) = make_glyph_instance(gx, gy, info.w, info.h);
3958                            batch.masks.push(GlyphInstance {
3959                                xywh: ndc,
3960                                uv: [info.u0, info.v1, info.u1, info.v0],
3961                                color: color.to_linear(),
3962                                sin_cos,
3963                            });
3964                        }
3965                    }
3966
3967                    // Upload slug vertices if any
3968                    if !slug_verts_local.is_empty() {
3969                        let bytes = bytemuck::cast_slice(&slug_verts_local);
3970                        self.slug_ring.grow_to_fit(&self.device, bytes.len() as u64);
3971                        let (off, _) = self.slug_ring.alloc_write(&self.queue, bytes);
3972                        current_pass.cmds.push(Cmd::GlyphsVector {
3973                            off,
3974                            cnt: slug_verts_local.len() as u32,
3975                        });
3976                        slug_verts_local.clear();
3977                    }
3978
3979                    // Text decoration: underline / strikethrough
3980                    if (text_decoration.underline || text_decoration.strikethrough)
3981                        && let Some(baseline_y) = baseline_y
3982                    {
3983                        flush_batch!();
3984                        current_prim = Some("rect");
3985                        let deco_color = text_decoration.color.unwrap_or(*color);
3986                        let thickness = (px * 0.07).max(1.0);
3987
3988                        if text_decoration.underline {
3989                            let dy = baseline_y + px * 0.1;
3990                            let (ndc, sin_cos) = rect_to_instance_ndc(
3991                                repose_core::Rect {
3992                                    x: rect.x,
3993                                    y: dy,
3994                                    w: rect.w,
3995                                    h: thickness,
3996                                },
3997                                current_transform,
3998                                current_target_size.0,
3999                                current_target_size.1,
4000                            );
4001                            batch.rects.push(RectInstance {
4002                                xywh: ndc,
4003                                radii: [0.0; 4],
4004                                brush_type: 0,
4005                                _pad: [0.0; 3],
4006                                color0: deco_color.to_linear(),
4007                                color1: [0.0; 4],
4008                                grad_start: [0.0; 2],
4009                                grad_end: [0.0; 2],
4010                                sin_cos,
4011                            });
4012                        }
4013                        if text_decoration.strikethrough {
4014                            let sy = baseline_y - px * 0.3;
4015                            let (ndc, sin_cos) = rect_to_instance_ndc(
4016                                repose_core::Rect {
4017                                    x: rect.x,
4018                                    y: sy,
4019                                    w: rect.w,
4020                                    h: thickness,
4021                                },
4022                                current_transform,
4023                                current_target_size.0,
4024                                current_target_size.1,
4025                            );
4026                            batch.rects.push(RectInstance {
4027                                xywh: ndc,
4028                                radii: [0.0; 4],
4029                                brush_type: 0,
4030                                _pad: [0.0; 3],
4031                                color0: deco_color.to_linear(),
4032                                color1: [0.0; 4],
4033                                grad_start: [0.0; 2],
4034                                grad_end: [0.0; 2],
4035                                sin_cos,
4036                            });
4037                        }
4038                    }
4039                }
4040                SceneNode::Image {
4041                    rect,
4042                    handle,
4043                    tint,
4044                    fit,
4045                } => {
4046                    flush_batch!();
4047
4048                    // Update usage timestamp for eviction
4049                    let (img_w, img_h, is_nv12) = if let Some(t) = self.images.get_mut(handle) {
4050                        match t {
4051                            ImageTex::Rgba {
4052                                w,
4053                                h,
4054                                last_used_frame,
4055                                ..
4056                            } => {
4057                                *last_used_frame = self.frame_index;
4058                                (*w, *h, false)
4059                            }
4060                            ImageTex::Nv12 {
4061                                w,
4062                                h,
4063                                last_used_frame,
4064                                ..
4065                            } => {
4066                                *last_used_frame = self.frame_index;
4067                                (*w, *h, true)
4068                            }
4069                        }
4070                    } else {
4071                        log::warn!("Image handle {} not found", handle);
4072                        continue;
4073                    };
4074
4075                    let src_w = img_w as f32;
4076                    let src_h = img_h as f32;
4077                    let transformed = current_transform.apply_to_rect(*rect);
4078                    let dst_w = transformed.w.max(0.0);
4079                    let dst_h = transformed.h.max(0.0);
4080                    if dst_w <= 0.0 || dst_h <= 0.0 {
4081                        continue;
4082                    }
4083
4084                    let (xywh_ndc, uv_rect) = match fit {
4085                        repose_core::view::ImageFit::Contain => {
4086                            let scale = (dst_w / src_w).min(dst_h / src_h);
4087                            let w = src_w * scale;
4088                            let h = src_h * scale;
4089                            let x = transformed.x + (dst_w - w) * 0.5;
4090                            let y = transformed.y + (dst_h - h) * 0.5;
4091                            (
4092                                to_ndc(x, y, w, h, current_target_size.0, current_target_size.1),
4093                                [0.0, 1.0, 1.0, 0.0],
4094                            )
4095                        }
4096                        repose_core::view::ImageFit::Cover => {
4097                            let scale = (dst_w / src_w).max(dst_h / src_h);
4098                            let content_w = src_w * scale;
4099                            let content_h = src_h * scale;
4100                            let overflow_x = (content_w - dst_w) * 0.5;
4101                            let overflow_y = (content_h - dst_h) * 0.5;
4102                            let u0 = (overflow_x / content_w).clamp(0.0, 1.0);
4103                            let v0 = (overflow_y / content_h).clamp(0.0, 1.0);
4104                            let u1 = ((overflow_x + dst_w) / content_w).clamp(0.0, 1.0);
4105                            let v1 = ((overflow_y + dst_h) / content_h).clamp(0.0, 1.0);
4106                            (
4107                                to_ndc(
4108                                    transformed.x,
4109                                    transformed.y,
4110                                    dst_w,
4111                                    dst_h,
4112                                    current_target_size.0,
4113                                    current_target_size.1,
4114                                ),
4115                                [u0, 1.0 - v1, u1, 1.0 - v0],
4116                            )
4117                        }
4118                        repose_core::view::ImageFit::FitWidth => {
4119                            let scale = dst_w / src_w;
4120                            let w = dst_w;
4121                            let h = src_h * scale;
4122                            let y = transformed.y + (dst_h - h) * 0.5;
4123                            (
4124                                to_ndc(
4125                                    transformed.x,
4126                                    y,
4127                                    w,
4128                                    h,
4129                                    current_target_size.0,
4130                                    current_target_size.1,
4131                                ),
4132                                [0.0, 1.0, 1.0, 0.0],
4133                            )
4134                        }
4135                        repose_core::view::ImageFit::FitHeight => {
4136                            let scale = dst_h / src_h;
4137                            let w = src_w * scale;
4138                            let h = dst_h;
4139                            let x = transformed.x + (dst_w - w) * 0.5;
4140                            (
4141                                to_ndc(
4142                                    x,
4143                                    transformed.y,
4144                                    w,
4145                                    h,
4146                                    current_target_size.0,
4147                                    current_target_size.1,
4148                                ),
4149                                [0.0, 1.0, 1.0, 0.0],
4150                            )
4151                        }
4152                        _ => ([0.0; 4], [0.0; 4]),
4153                    };
4154
4155                    // Convert top-left based NDC to center-based for shader
4156                    let ndc_center = [
4157                        xywh_ndc[0] + xywh_ndc[2] * 0.5,
4158                        xywh_ndc[1] + xywh_ndc[3] * 0.5,
4159                        xywh_ndc[2],
4160                        xywh_ndc[3],
4161                    ];
4162
4163                    if is_nv12 {
4164                        let uv_x_offset = if let Some(ImageTex::Nv12 { w, color_info, .. }) =
4165                            self.images.get(handle)
4166                        {
4167                            match color_info.chroma_siting {
4168                                ChromaSiting::Center | ChromaSiting::TopLeft => 0.0,
4169                                ChromaSiting::Left => -1.0 / *w as f32,
4170                            }
4171                        } else {
4172                            0.0
4173                        };
4174
4175                        let inst = Nv12Instance {
4176                            xywh: ndc_center,
4177                            uv: uv_rect,
4178                            color: tint.to_linear(),
4179                            uv_x_offset,
4180                            sin_cos: [1.0, 0.0],
4181                            _pad: [0.0],
4182                        };
4183                        if let Some((off, _)) = self.nv12.upload(&self.device, &self.queue, &[inst])
4184                        {
4185                            current_pass.cmds.push(Cmd::ImageNv12 {
4186                                off,
4187                                cnt: 1,
4188                                handle: *handle,
4189                            });
4190                        }
4191                    } else {
4192                        // RGBA uses GlyphInstance struct (reused pipeline)
4193                        let inst = GlyphInstance {
4194                            xywh: ndc_center,
4195                            uv: uv_rect,
4196                            color: tint.to_linear(),
4197                            sin_cos: [1.0, 0.0],
4198                        };
4199                        if let Some((off, _)) =
4200                            self.glyph_color.upload(&self.device, &self.queue, &[inst])
4201                        {
4202                            current_pass.cmds.push(Cmd::ImageRgba {
4203                                off,
4204                                cnt: 1,
4205                                handle: *handle,
4206                            });
4207                        }
4208                    }
4209                }
4210                SceneNode::PushClip { rect, radius, op } => {
4211                    flush_batch!(); // flush content before entering clip
4212
4213                    let is_diff = matches!(op, repose_core::ClipOp::Difference);
4214
4215                    let t_identity = Transform::identity();
4216                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
4217                    let transformed = current_transform.apply_to_rect(*rect);
4218
4219                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
4220                    let next_scissor = if is_diff {
4221                        top
4222                    } else {
4223                        intersect(top, transformed)
4224                    };
4225                    scissor_stack.push(next_scissor);
4226                    let scissor = to_scissor(
4227                        &next_scissor,
4228                        current_target_size.0 as u32,
4229                        current_target_size.1 as u32,
4230                    );
4231
4232                    let clip_ndc_tl = to_ndc(
4233                        transformed.x,
4234                        transformed.y,
4235                        transformed.w,
4236                        transformed.h,
4237                        current_target_size.0,
4238                        current_target_size.1,
4239                    );
4240                    let inst = ClipInstance {
4241                        xywh: [
4242                            clip_ndc_tl[0] + clip_ndc_tl[2] * 0.5,
4243                            clip_ndc_tl[1] + clip_ndc_tl[3] * 0.5,
4244                            clip_ndc_tl[2],
4245                            clip_ndc_tl[3],
4246                        ],
4247                        radii: *radius,
4248                        sin_cos: [1.0, 0.0],
4249                    };
4250                    let bytes = bytemuck::bytes_of(&inst);
4251                    self.clip_ring.grow_to_fit(&self.device, bytes.len() as u64);
4252                    let (off, _) = self.clip_ring.alloc_write(&self.queue, bytes);
4253
4254                    let rounded = radius.iter().any(|&r| r > 0.5);
4255
4256                    current_pass.cmds.push(Cmd::ClipPush {
4257                        off,
4258                        cnt: 1,
4259                        scissor,
4260                        difference: is_diff,
4261                        rounded,
4262                    });
4263                }
4264                SceneNode::PopClip => {
4265                    flush_batch!();
4266
4267                    if !scissor_stack.is_empty() {
4268                        scissor_stack.pop();
4269                    } else {
4270                        log::warn!("PopClip with empty stack");
4271                    }
4272
4273                    let top = scissor_stack.last().copied().unwrap_or(root_clip_rect);
4274                    let scissor = to_scissor(
4275                        &top,
4276                        current_target_size.0 as u32,
4277                        current_target_size.1 as u32,
4278                    );
4279                    current_pass.cmds.push(Cmd::ClipPop { scissor });
4280                }
4281                SceneNode::Shadow {
4282                    rect,
4283                    radius,
4284                    elevation: _,
4285                    color,
4286                } => {
4287                    flush_if_prim_changed!("rect", &self.rects);
4288                    let (ndc, sin_cos) = rect_to_instance_ndc(
4289                        *rect,
4290                        current_transform,
4291                        current_target_size.0,
4292                        current_target_size.1,
4293                    );
4294                    let (brush_type, color0, _color1, _grad_start, _grad_end) =
4295                        brush_to_instance_fields(&Brush::Solid(*color));
4296                    batch.rects.push(RectInstance {
4297                        xywh: ndc,
4298                        radii: *radius,
4299                        brush_type,
4300                        _pad: [0.0; 3],
4301                        color0,
4302                        color1: [0.0; 4],
4303                        grad_start: [0.0; 2],
4304                        grad_end: [0.0; 2],
4305                        sin_cos,
4306                    });
4307                }
4308                SceneNode::PushTransform { transform } => {
4309                    flush_batch!(); // flush before transform change
4310                    let combined = current_transform.combine(transform);
4311                    transform_stack.push(combined);
4312                }
4313                SceneNode::PopTransform => {
4314                    flush_batch!(); // flush before transform change
4315                    transform_stack.pop();
4316                }
4317                SceneNode::BeginLayer {
4318                    rect,
4319                    layer_id,
4320                    alpha,
4321                    blur_radius_x,
4322                    blur_radius_y,
4323                    rectangle_edge: _,
4324                } => {
4325                    flush_batch!();
4326                    // Layer rect is already snapped to whole pixels in layout;
4327                    // round() keeps any bypass of that snap consistent.
4328                    let w = (rect.w.round().max(1.0)) as u32;
4329                    let h = (rect.h.round().max(1.0)) as u32;
4330                    // Close out the current pass, start a new one for the layer.
4331                    let prev_target = current_pass.target;
4332                    let prev_scissor = current_pass.initial_scissor;
4333                    let saved = std::mem::replace(
4334                        &mut current_pass,
4335                        Pass {
4336                            target: PassTarget::Layer(*layer_id),
4337                            initial_scissor: (0, 0, w, h),
4338                            clear_color: Some([0.0, 0.0, 0.0, 0.0]),
4339                            cmds: Vec::new(),
4340                        },
4341                    );
4342                    passes.push(saved);
4343                    target_stack.push(prev_target);
4344                    let _ = prev_scissor; // initial_scissor of resumed pass is restored at EndLayer
4345                    // Get or create the layer's offscreen texture now so that
4346                    // subsequent scissor ops / draws have a valid target.
4347                    self.get_or_create_layer(*layer_id, w, h, *rect);
4348                    current_target_size = (w as f32, h as f32);
4349                    layer_alphas.push((*layer_id, *alpha, current_pass.initial_scissor));
4350                    // Store blur info for post-processing after EndLayer
4351                    if *blur_radius_x > 0.0 || *blur_radius_y > 0.0 {
4352                        layer_blurs.push((*layer_id, *blur_radius_x, *blur_radius_y));
4353                    }
4354                }
4355                SceneNode::EndLayer { layer_id } => {
4356                    flush_batch!();
4357                    // Finish the layer's pass, start a new one on the previous target.
4358                    let saved = std::mem::replace(
4359                        &mut current_pass,
4360                        Pass {
4361                            target: target_stack.pop().unwrap_or(PassTarget::Surface),
4362                            initial_scissor: (0, 0, self.output_width, self.output_height),
4363                            clear_color: None, // LoadOp::Load - don't wipe earlier surface content
4364                            cmds: Vec::new(),
4365                        },
4366                    );
4367                    passes.push(saved);
4368                    current_target_size = (fb_w, fb_h);
4369                    // Issue a composite quad for the just-finished layer in the new pass.
4370                    if let Some((_, layer_alpha, _)) = layer_alphas
4371                        .iter()
4372                        .find(|(id, _, _)| id == layer_id)
4373                        .copied()
4374                    {
4375                        let layer = self.layer_pool.get(layer_id).expect("layer target");
4376                        let ndc_tl = to_ndc(
4377                            layer.rect_px.0,
4378                            layer.rect_px.1,
4379                            layer.rect_px.2,
4380                            layer.rect_px.3,
4381                            fb_w,
4382                            fb_h,
4383                        );
4384                        let uv_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
4385                        let uv_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
4386                        // Check if this layer needs content blur
4387                        let blur_px_val = layer_blurs
4388                            .iter()
4389                            .find(|(id, _, _)| id == layer_id)
4390                            .map(|(_, bx, by)| (*bx, *by));
4391                        if let Some((blur_x, blur_y)) =
4392                            blur_px_val.filter(|(bx, by)| *bx > 0.0 || *by > 0.0)
4393                        {
4394                            // Content blur: draw blurred version using the blur_content pipeline
4395                            let bw_uv = (blur_x * 1.5) / layer.width.max(1) as f32;
4396                            let bh_uv = (blur_y * 1.5) / layer.height.max(1) as f32;
4397                            let inst = BlurInstance {
4398                                xywh: [
4399                                    ndc_tl[0] + ndc_tl[2] * 0.5,
4400                                    ndc_tl[1] + ndc_tl[3] * 0.5,
4401                                    ndc_tl[2],
4402                                    ndc_tl[3],
4403                                ],
4404                                uv: [0.0, 0.0, uv_u1, uv_v1],
4405                                color: [1.0, 1.0, 1.0, layer_alpha],
4406                                blur_uv: [bw_uv, bh_uv],
4407                                sin_cos: [1.0, 0.0],
4408                            };
4409                            self.blur_ring.grow_to_fit(
4410                                &self.device,
4411                                std::mem::size_of::<BlurInstance>() as u64,
4412                            );
4413                            let bytes = bytemuck::bytes_of(&inst);
4414                            let (off, _) = self.blur_ring.alloc_write(&self.queue, bytes);
4415                            current_pass.cmds.push(Cmd::CompositeBlur {
4416                                off,
4417                                cnt: 1,
4418                                layer_id: *layer_id,
4419                            });
4420                        } else {
4421                            // Normal sharp composite
4422                            let inst = GlyphInstance {
4423                                xywh: [
4424                                    ndc_tl[0] + ndc_tl[2] * 0.5,
4425                                    ndc_tl[1] + ndc_tl[3] * 0.5,
4426                                    ndc_tl[2],
4427                                    ndc_tl[3],
4428                                ],
4429                                uv: [0.0, uv_v1, uv_u1, 0.0],
4430                                color: [1.0, 1.0, 1.0, layer_alpha],
4431                                sin_cos: [1.0, 0.0],
4432                            };
4433                            if let Some((off, cnt)) =
4434                                self.glyph_color.upload(&self.device, &self.queue, &[inst])
4435                            {
4436                                current_pass.cmds.push(Cmd::CompositeLayer {
4437                                    off,
4438                                    cnt,
4439                                    layer_id: *layer_id,
4440                                    alpha: layer_alpha,
4441                                });
4442                            }
4443                        }
4444                    }
4445                }
4446                SceneNode::CompositeShadow {
4447                    layer_id,
4448                    blur_px,
4449                    offset_px,
4450                    color,
4451                } => {
4452                    flush_batch!();
4453                    if let Some(layer) = self.layer_pool.get(layer_id).cloned() {
4454                        // Shadow rect = layer rect + offset.
4455                        let sx = layer.rect_px.0 + offset_px.0;
4456                        let sy = layer.rect_px.1 + offset_px.1;
4457                        let sw = layer.rect_px.2;
4458                        let sh = layer.rect_px.3;
4459                        // The blur in UV space is 1.5 * blur_px / texture_size
4460                        // (the 1.5 matches the 3x3 Gaussian span).
4461                        let bw_uv = (blur_px * 1.5) / layer.width.max(1) as f32;
4462                        let bh_uv = (blur_px * 1.5) / layer.height.max(1) as f32;
4463                        let shadow_u1 = layer.rect_px.2 / layer.width.max(1) as f32;
4464                        let shadow_v1 = layer.rect_px.3 / layer.height.max(1) as f32;
4465                        let ndc_tl = to_ndc(sx, sy, sw, sh, fb_w, fb_h);
4466                        let inst = BlurInstance {
4467                            xywh: [
4468                                ndc_tl[0] + ndc_tl[2] * 0.5,
4469                                ndc_tl[1] + ndc_tl[3] * 0.5,
4470                                ndc_tl[2],
4471                                ndc_tl[3],
4472                            ],
4473                            uv: [0.0, 0.0, shadow_u1, shadow_v1],
4474                            color: [
4475                                color.0 as f32 / 255.0,
4476                                color.1 as f32 / 255.0,
4477                                color.2 as f32 / 255.0,
4478                                color.3 as f32 / 255.0,
4479                            ],
4480                            blur_uv: [bw_uv, bh_uv],
4481                            sin_cos: [1.0, 0.0],
4482                        };
4483                        self.blur_ring
4484                            .grow_to_fit(&self.device, std::mem::size_of::<BlurInstance>() as u64);
4485                        let bytes = bytemuck::bytes_of(&inst);
4486                        let (off, _) = self.blur_ring.alloc_write(&self.queue, bytes);
4487                        current_pass.cmds.push(Cmd::CompositeShadow {
4488                            off,
4489                            cnt: 1,
4490                            layer_id: *layer_id,
4491                        });
4492                    }
4493                }
4494                _ => {}
4495            }
4496        }
4497
4498        flush_batch!();
4499
4500        // Push the final pass.
4501        passes.push(current_pass);
4502
4503        let globals_bytes = std::mem::size_of::<Globals>() as u64;
4504        let globals_staging = self.device.create_buffer(&wgpu::BufferDescriptor {
4505            label: Some("globals staging"),
4506            size: (passes.len().max(1) as u64) * globals_bytes,
4507            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
4508            mapped_at_creation: false,
4509        });
4510        for (i, pass) in passes.iter().enumerate() {
4511            let (target_w, target_h) = match pass.target {
4512                PassTarget::Surface => (fb_w, fb_h),
4513                PassTarget::Layer(layer_id) => {
4514                    let lt = self.layer_pool.get(&layer_id);
4515                    (
4516                        lt.map_or(fb_w, |l| l.width as f32),
4517                        lt.map_or(fb_h, |l| l.height as f32),
4518                    )
4519                }
4520            };
4521            self.queue.write_buffer(
4522                &globals_staging,
4523                (i as u64) * globals_bytes,
4524                bytemuck::bytes_of(&make_globals(target_w, target_h)),
4525            );
4526        }
4527
4528        let bind_mask = self.atlas_bind_group_mask();
4529        let bind_color = self.atlas_bind_group_color();
4530        let mut clip_depth: u32 = 0;
4531
4532        for (pass_index, pass) in std::mem::take(&mut passes).into_iter().enumerate() {
4533            let (color_view, resolve_target, depth_stencil_view, is_layer) = match pass.target {
4534                PassTarget::Surface => {
4535                    let swap_view = target_view.clone();
4536                    let use_ws = self.working_space && self.ws_view.is_some();
4537                    let (color, resolve) = if use_ws {
4538                        let ws_view = self.ws_view.as_ref().unwrap();
4539                        if let Some(msaa_view) = &self.msaa_view {
4540                            // MSAA resolves to working-space texture
4541                            (msaa_view.clone(), Some(ws_view.clone()))
4542                        } else {
4543                            // Direct render to working-space texture
4544                            (ws_view.clone(), None)
4545                        }
4546                    } else if let Some(msaa_view) = &self.msaa_view {
4547                        (msaa_view.clone(), Some(swap_view))
4548                    } else {
4549                        (swap_view, None)
4550                    };
4551                    (color, resolve, self.depth_stencil_view.clone(), false)
4552                }
4553                PassTarget::Layer(layer_id) => {
4554                    if let Some(lt) = self.layer_pool.get(&layer_id) {
4555                        (lt.view.clone(), None, lt.depth_stencil_view.clone(), true)
4556                    } else {
4557                        log::warn!("missing layer target {layer_id}");
4558                        continue;
4559                    }
4560                }
4561            };
4562
4563            encoder.copy_buffer_to_buffer(
4564                &globals_staging,
4565                (pass_index as u64) * globals_bytes,
4566                &self.globals_buf,
4567                0,
4568                globals_bytes,
4569            );
4570
4571            if is_layer {
4572                clip_depth = 0;
4573            }
4574
4575            let pipes: &Pipelines = if is_layer {
4576                &self.layer_pipes
4577            } else {
4578                &self.surface_pipes
4579            };
4580
4581            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4582                label: Some("pass"),
4583                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4584                    view: &color_view,
4585                    resolve_target: resolve_target.as_ref(),
4586                    ops: wgpu::Operations {
4587                        load: match pass.clear_color {
4588                            Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
4589                                r: c[0] as f64,
4590                                g: c[1] as f64,
4591                                b: c[2] as f64,
4592                                a: c[3] as f64,
4593                            }),
4594                            None => wgpu::LoadOp::Load,
4595                        },
4596                        store: wgpu::StoreOp::Store,
4597                    },
4598                    depth_slice: None,
4599                })],
4600                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4601                    view: &depth_stencil_view,
4602                    depth_ops: None,
4603                    stencil_ops: Some(wgpu::Operations {
4604                        load: if is_layer || pass.clear_color.is_some() {
4605                            wgpu::LoadOp::Clear(0)
4606                        } else {
4607                            wgpu::LoadOp::Load
4608                        },
4609                        store: wgpu::StoreOp::Store,
4610                    }),
4611                }),
4612                timestamp_writes: None,
4613                occlusion_query_set: None,
4614                multiview_mask: None,
4615            });
4616
4617            rpass.set_bind_group(0, &self.globals_bind, &[]);
4618            rpass.set_stencil_reference(clip_depth);
4619            rpass.set_scissor_rect(
4620                pass.initial_scissor.0,
4621                pass.initial_scissor.1,
4622                pass.initial_scissor.2,
4623                pass.initial_scissor.3,
4624            );
4625
4626            macro_rules! draw_simple {
4627                ($pipeline:expr, $ring:expr, $inst:ty, $off:ident, $n:ident) => {{
4628                    rpass.set_pipeline($pipeline);
4629                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
4630                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
4631                    rpass.draw(0..6, 0..$n);
4632                }};
4633            }
4634
4635            macro_rules! draw_with_bind {
4636                ($pipeline:expr, $ring:expr, $inst:ty, $bind:expr, $off:ident, $n:ident) => {{
4637                    rpass.set_pipeline($pipeline);
4638                    rpass.set_bind_group(1, $bind, &[]);
4639                    let bytes = ($n as u64) * std::mem::size_of::<$inst>() as u64;
4640                    rpass.set_vertex_buffer(0, $ring.buf.slice($off..$off + bytes));
4641                    rpass.draw(0..6, 0..$n);
4642                }};
4643            }
4644
4645            for cmd in pass.cmds {
4646                match cmd {
4647                    Cmd::ClipPush {
4648                        off,
4649                        cnt: n,
4650                        scissor,
4651                        difference,
4652                        rounded,
4653                    } => {
4654                        rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
4655                        rpass.set_stencil_reference(clip_depth);
4656
4657                        if difference {
4658                            rpass.set_pipeline(&pipes.clip_dec);
4659                        } else if self.msaa_samples > 1 && !is_layer && rounded {
4660                            rpass.set_pipeline(&pipes.clip_a2c);
4661                        } else {
4662                            rpass.set_pipeline(&pipes.clip_bin);
4663                        }
4664
4665                        let bytes = (n as u64) * std::mem::size_of::<ClipInstance>() as u64;
4666                        rpass.set_vertex_buffer(0, self.clip_ring.buf.slice(off..off + bytes));
4667                        rpass.draw(0..6, 0..n);
4668
4669                        if !difference {
4670                            clip_depth = (clip_depth + 1).min(255);
4671                            rpass.set_stencil_reference(clip_depth);
4672                        }
4673                    }
4674
4675                    Cmd::ClipPop { scissor } => {
4676                        clip_depth = clip_depth.saturating_sub(1);
4677                        rpass.set_stencil_reference(clip_depth);
4678                        rpass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
4679                    }
4680
4681                    Cmd::Rect { off, cnt: n } => {
4682                        draw_simple!(&pipes.rects, self.rects.ring, RectInstance, off, n);
4683                    }
4684
4685                    Cmd::Border { off, cnt: n } => {
4686                        draw_simple!(&pipes.borders, self.borders.ring, BorderInstance, off, n);
4687                    }
4688
4689                    Cmd::GlyphsMask { off, cnt: n } => {
4690                        draw_with_bind!(
4691                            &pipes.text_mask,
4692                            self.glyph_mask.ring,
4693                            GlyphInstance,
4694                            &bind_mask,
4695                            off,
4696                            n
4697                        );
4698                    }
4699
4700                    Cmd::GlyphsColor { off, cnt: n } => {
4701                        draw_with_bind!(
4702                            &pipes.text_color,
4703                            self.glyph_color.ring,
4704                            GlyphInstance,
4705                            &bind_color,
4706                            off,
4707                            n
4708                        );
4709                    }
4710
4711                    Cmd::GlyphsVector { off, cnt: n } => {
4712                        if let Some(ref slug_pipe) = pipes.slug.as_ref() {
4713                            rpass.set_pipeline(slug_pipe);
4714                            let bytes = (n as u64) * std::mem::size_of::<slug::TessVertex>() as u64;
4715                            rpass.set_vertex_buffer(0, self.slug_ring.buf.slice(off..off + bytes));
4716                            rpass.draw(0..n, 0..1);
4717                        }
4718                    }
4719
4720                    Cmd::ImageRgba {
4721                        off,
4722                        cnt: n,
4723                        handle,
4724                    } => {
4725                        if let Some(ImageTex::Rgba { bind, .. }) = self.images.get(&handle) {
4726                            draw_with_bind!(
4727                                &pipes.image_rgba,
4728                                self.glyph_color.ring,
4729                                GlyphInstance,
4730                                bind,
4731                                off,
4732                                n
4733                            );
4734                        }
4735                    }
4736
4737                    Cmd::ImageNv12 {
4738                        off,
4739                        cnt: n,
4740                        handle,
4741                    } => {
4742                        if let Some(ImageTex::Nv12 { bind, .. }) = self.images.get(&handle) {
4743                            draw_with_bind!(
4744                                &pipes.image_nv12,
4745                                self.nv12.ring,
4746                                Nv12Instance,
4747                                bind,
4748                                off,
4749                                n
4750                            );
4751                        }
4752                    }
4753
4754                    Cmd::Ellipse { off, cnt: n } => {
4755                        draw_simple!(&pipes.ellipses, self.ellipses.ring, EllipseInstance, off, n);
4756                    }
4757
4758                    Cmd::EllipseBorder { off, cnt: n } => {
4759                        draw_simple!(
4760                            &pipes.ellipse_borders,
4761                            self.ellipse_borders.ring,
4762                            EllipseBorderInstance,
4763                            off,
4764                            n
4765                        );
4766                    }
4767
4768                    Cmd::Arc { off, cnt: n } => {
4769                        draw_simple!(&pipes.arcs, self.arcs.ring, ArcInstance, off, n);
4770                    }
4771
4772                    Cmd::PushTransform(_) => {}
4773                    Cmd::PopTransform => {}
4774                    Cmd::CompositeLayer {
4775                        off,
4776                        cnt: n,
4777                        layer_id,
4778                        alpha: _,
4779                    } => {
4780                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
4781                            draw_with_bind!(
4782                                &pipes.image_rgba,
4783                                self.glyph_color.ring,
4784                                GlyphInstance,
4785                                &lt.bind,
4786                                off,
4787                                n
4788                            );
4789                        }
4790                    }
4791                    Cmd::CompositeShadow {
4792                        off,
4793                        cnt: n,
4794                        layer_id,
4795                    } => {
4796                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
4797                            draw_with_bind!(
4798                                &pipes.blur,
4799                                self.blur_ring,
4800                                BlurInstance,
4801                                &lt.bind_linear,
4802                                off,
4803                                n
4804                            );
4805                        }
4806                    }
4807                    Cmd::CompositeBlur {
4808                        off,
4809                        cnt: n,
4810                        layer_id,
4811                    } => {
4812                        if let Some(lt) = self.layer_pool.get(&layer_id).cloned() {
4813                            draw_with_bind!(
4814                                &pipes.blur_content,
4815                                self.blur_ring,
4816                                BlurInstance,
4817                                &lt.bind_linear,
4818                                off,
4819                                n
4820                            );
4821                        }
4822                    }
4823                }
4824            }
4825        }
4826
4827        // Display pass: linear working space → sRGB OETF → swapchain
4828        if self.working_space {
4829            if let (Some(_ws_view), Some(ws_bind), Some(display_pipeline)) =
4830                (&self.ws_view, &self.ws_bind, &self.display_pipeline)
4831            {
4832                let swap_view = target_view.clone();
4833                let mut display_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4834                    label: Some("display transform"),
4835                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4836                        view: &swap_view,
4837                        resolve_target: None,
4838                        ops: wgpu::Operations {
4839                            load: wgpu::LoadOp::Load,
4840                            store: wgpu::StoreOp::Store,
4841                        },
4842                        depth_slice: None,
4843                    })],
4844                    depth_stencil_attachment: None,
4845                    timestamp_writes: None,
4846                    occlusion_query_set: None,
4847                    multiview_mask: None,
4848                });
4849                display_pass.set_pipeline(display_pipeline);
4850                display_pass.set_bind_group(1, ws_bind, &[]);
4851                display_pass.draw(0..3, 0..1);
4852            }
4853        }
4854
4855
4856        // Frame end maintenance: Evict unused images
4857        self.evict_unused_images();
4858    }
4859
4860    /// Render a scene into an externally-provided texture view.
4861    /// Use this when embedding Repose in a host that owns the GPU.
4862    /// The host is responsible for submitting the encoder and handling present.
4863    pub fn render_to_view(
4864        &mut self,
4865        scene: &Scene,
4866        encoder: &mut wgpu::CommandEncoder,
4867        target_view: &wgpu::TextureView,
4868        width: u32,
4869        height: u32,
4870        clear_color: Option<[f64; 4]>,
4871    ) {
4872        self.resize(width, height);
4873
4874        self.frame_index = self.frame_index.wrapping_add(1);
4875        self.slug_cache.next_frame();
4876
4877        if width == 0 || height == 0 {
4878            return;
4879        }
4880
4881        self.render_scene_to_encoder(scene, encoder, target_view, clear_color);
4882    }
4883}
4884
4885
4886fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
4887    let x0 = a.x.max(b.x);
4888    let y0 = a.y.max(b.y);
4889    let x1 = (a.x + a.w).min(b.x + b.w);
4890    let y1 = (a.y + a.h).min(b.y + b.h);
4891    repose_core::Rect {
4892        x: x0,
4893        y: y0,
4894        w: (x1 - x0).max(0.0),
4895        h: (y1 - y0).max(0.0),
4896    }
4897}