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