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