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