Skip to main content

jag_surface/
surface.rs

1use std::sync::{Arc, Mutex};
2
3use anyhow::Result;
4
5use jag_draw::{
6    ColorLinPremul,
7    Command,
8    DisplayList,
9    ExternalTextureId,
10    HitIndex,
11    Painter,
12    PassManager,
13    Rect,
14    RenderAllocator,
15    Transform2D,
16    Viewport,
17    wgpu, // import wgpu from engine-core to keep type identity
18};
19
20use crate::canvas::{Canvas, ImageFitMode};
21
22/// Cached GPU resources from a previous `end_frame` call, enabling scroll-only
23/// frames to skip the expensive IR walk, display list build, and GPU upload.
24/// Instead, the cached buffers are re-rendered with a delta scroll offset applied
25/// via the viewport uniform.
26#[allow(clippy::type_complexity)]
27pub struct CachedFrameData {
28    /// The GPU scene (vertex/index buffers for opaque geometry).
29    pub gpu_scene: jag_draw::GpuScene,
30    /// The GPU scene for transparent (per-z-batch) geometry.
31    pub transparent_gpu_scene: jag_draw::GpuScene,
32    /// Per-z-index ranges in the transparent index buffer.
33    pub transparent_batches: Vec<jag_draw::TransparentBatch>,
34    /// Pre-rasterized glyph draws.
35    pub glyph_draws: Vec<(
36        [f32; 2],
37        jag_draw::RasterizedGlyph,
38        jag_draw::ColorLinPremul,
39        i32,
40    )>,
41    /// Resolved SVG draws.
42    pub svg_draws: Vec<(
43        std::path::PathBuf,
44        [f32; 2],
45        [f32; 2],
46        Option<jag_draw::SvgStyle>,
47        i32,
48        Transform2D,
49        Option<jag_draw::Rect>,
50    )>,
51    /// Resolved image draws.
52    pub image_draws: Vec<(
53        std::path::PathBuf,
54        [f32; 2],
55        [f32; 2],
56        i32,
57        Option<jag_draw::Rect>,
58    )>,
59    /// External texture draws (e.g. Canvas3D, opacity group layers).
60    pub external_texture_draws: Vec<jag_draw::ExtractedExternalTextureDraw>,
61    /// Clear color used for this frame.
62    pub clear: wgpu::Color,
63    /// Whether the frame was rendered directly (vs intermediate texture).
64    pub direct: bool,
65    /// Frame dimensions.
66    pub width: u32,
67    pub height: u32,
68    /// Scroll offset at the time the frame was built, for computing the delta.
69    pub scroll_at_build: (f32, f32),
70    /// Visual generation at build time.
71    pub generation_at_build: u64,
72    /// Viewport size at build time (for invalidation).
73    pub viewport_size: (u32, u32),
74    /// The hit index from the built frame (reused during scroll-only frames).
75    pub hit_index: jag_draw::HitIndex,
76}
77
78/// Apply a 2D affine transform to a point
79fn apply_transform_to_point(point: [f32; 2], transform: Transform2D) -> [f32; 2] {
80    let [a, b, c, d, e, f] = transform.m;
81    let x = point[0];
82    let y = point[1];
83    [a * x + c * y + e, b * x + d * y + f]
84}
85
86/// Storage for the last rendered raw image rect (used for hit testing WebViews).
87static LAST_RAW_IMAGE_RECT: Mutex<Option<(f32, f32, f32, f32)>> = Mutex::new(None);
88
89/// Set the last raw image rect (called during rendering).
90fn set_last_raw_image_rect(x: f32, y: f32, w: f32, h: f32) {
91    if let Ok(mut guard) = LAST_RAW_IMAGE_RECT.lock() {
92        *guard = Some((x, y, w, h));
93    }
94}
95
96/// Get the last raw image rect (for hit testing from FFI).
97pub fn get_last_raw_image_rect() -> Option<(f32, f32, f32, f32)> {
98    if let Ok(guard) = LAST_RAW_IMAGE_RECT.lock() {
99        *guard
100    } else {
101        None
102    }
103}
104
105/// Overlay callback signature: called after main rendering with full PassManager access.
106/// Allows scenes to draw overlays (like SVG ticks) directly to the surface.
107pub type OverlayCallback = Box<
108    dyn FnMut(
109        &mut PassManager,
110        &mut wgpu::CommandEncoder,
111        &wgpu::TextureView,
112        &wgpu::Queue,
113        u32,
114        u32,
115    ),
116>;
117
118/// Calculate the actual render origin and size for an image based on fit mode.
119/// Returns (origin, size) where the image should be drawn.
120fn calculate_image_fit(
121    origin: [f32; 2],
122    bounds: [f32; 2],
123    img_w: f32,
124    img_h: f32,
125    fit: ImageFitMode,
126) -> ([f32; 2], [f32; 2]) {
127    match fit {
128        ImageFitMode::Fill => {
129            // Stretch to fill - use bounds as-is
130            (origin, bounds)
131        }
132        ImageFitMode::Contain => {
133            // Fit inside maintaining aspect ratio
134            let bounds_aspect = bounds[0] / bounds[1];
135            let img_aspect = img_w / img_h;
136
137            let (render_w, render_h) = if img_aspect > bounds_aspect {
138                // Image is wider - fit to width
139                (bounds[0], bounds[0] / img_aspect)
140            } else {
141                // Image is taller - fit to height
142                (bounds[1] * img_aspect, bounds[1])
143            };
144
145            // Center within bounds
146            let offset_x = (bounds[0] - render_w) * 0.5;
147            let offset_y = (bounds[1] - render_h) * 0.5;
148
149            (
150                [origin[0] + offset_x, origin[1] + offset_y],
151                [render_w, render_h],
152            )
153        }
154        ImageFitMode::Cover => {
155            // Fill maintaining aspect ratio (may crop)
156            let bounds_aspect = bounds[0] / bounds[1];
157            let img_aspect = img_w / img_h;
158
159            let (render_w, render_h) = if img_aspect > bounds_aspect {
160                // Image is wider - fit to height
161                (bounds[1] * img_aspect, bounds[1])
162            } else {
163                // Image is taller - fit to width
164                (bounds[0], bounds[0] / img_aspect)
165            };
166
167            // Center within bounds (will be clipped)
168            let offset_x = (bounds[0] - render_w) * 0.5;
169            let offset_y = (bounds[1] - render_h) * 0.5;
170
171            (
172                [origin[0] + offset_x, origin[1] + offset_y],
173                [render_w, render_h],
174            )
175        }
176    }
177}
178
179/// High-level canvas-style wrapper over Painter + PassManager.
180///
181/// Typical flow:
182/// - let mut canvas = surface.begin_frame(w, h);
183/// - canvas.clear(color);
184/// - canvas.draw calls ...
185/// - surface.end_frame(frame, canvas);
186pub struct JagSurface {
187    device: Arc<wgpu::Device>,
188    queue: Arc<wgpu::Queue>,
189    surface_format: wgpu::TextureFormat,
190    pass: PassManager,
191    allocator: RenderAllocator,
192    /// When true, render directly to the surface; otherwise render offscreen then composite.
193    direct: bool,
194    /// When true, preserve existing surface content (LoadOp::Load) instead of clearing.
195    preserve_surface: bool,
196    /// When true, render solids to an intermediate texture and blit to the surface.
197    /// This matches the demo-app default and is often more robust across platforms during resize.
198    use_intermediate: bool,
199    /// When true, positions are interpreted as logical pixels and scaled by dpi_scale in PassManager.
200    logical_pixels: bool,
201    /// Current DPI scale factor (e.g., 2.0 on Retina).
202    dpi_scale: f32,
203    /// When true, run SMAA resolve; when false, favor a direct blit for crisper text.
204    enable_smaa: bool,
205    /// Additional UI scale multiplier
206    ui_scale: f32,
207    /// Optional overlay callback for post-render passes (e.g., SVG overlays)
208    overlay: Option<OverlayCallback>,
209    /// Monotonic allocator for internally-generated external texture IDs
210    /// (used for opacity group compositing layers).
211    next_synthetic_external_texture_id: u64,
212    /// Cached frame data from the most recent `end_frame` call, enabling
213    /// scroll-only frames to skip the IR walk and GPU upload.
214    frame_cache: Option<CachedFrameData>,
215}
216
217impl JagSurface {
218    /// Create a new surface wrapper using an existing device/queue and the chosen surface format.
219    pub fn new(
220        device: Arc<wgpu::Device>,
221        queue: Arc<wgpu::Queue>,
222        surface_format: wgpu::TextureFormat,
223    ) -> Self {
224        let pass = PassManager::new(device.clone(), surface_format);
225        let allocator = RenderAllocator::new(device.clone());
226
227        Self {
228            device,
229            queue,
230            surface_format,
231            pass,
232            allocator,
233            direct: false,
234            preserve_surface: false,
235            use_intermediate: true,
236            logical_pixels: true,
237            dpi_scale: 1.0,
238            enable_smaa: false,
239            ui_scale: 1.0,
240            overlay: None,
241            next_synthetic_external_texture_id: 0x7000_0000_0000_0000,
242            frame_cache: None,
243        }
244    }
245
246    /// Convenience: construct from shared device/queue handles.
247    pub fn from_device_queue(
248        device: Arc<wgpu::Device>,
249        queue: Arc<wgpu::Queue>,
250        surface_format: wgpu::TextureFormat,
251    ) -> Self {
252        Self::new(device, queue, surface_format)
253    }
254
255    pub fn device(&self) -> Arc<wgpu::Device> {
256        self.device.clone()
257    }
258    pub fn queue(&self) -> Arc<wgpu::Queue> {
259        self.queue.clone()
260    }
261    pub fn surface_format(&self) -> wgpu::TextureFormat {
262        self.surface_format
263    }
264    pub fn pass_manager(&mut self) -> &mut PassManager {
265        &mut self.pass
266    }
267    pub fn allocator_mut(&mut self) -> &mut RenderAllocator {
268        &mut self.allocator
269    }
270
271    /// Choose whether to render directly to the surface (bypass compositor).
272    pub fn set_direct(&mut self, direct: bool) {
273        self.direct = direct;
274    }
275    /// Control whether to preserve existing contents on the surface.
276    pub fn set_preserve_surface(&mut self, preserve: bool) {
277        self.preserve_surface = preserve;
278    }
279    /// Choose whether to use an intermediate texture and blit to the surface.
280    pub fn set_use_intermediate(&mut self, use_it: bool) {
281        self.use_intermediate = use_it;
282    }
283    /// Enable or disable SMAA. Disabling skips the post-process filter to keep small text crisp.
284    pub fn set_enable_smaa(&mut self, enable: bool) {
285        self.enable_smaa = enable;
286    }
287    /// Enable or disable logical pixel interpretation.
288    pub fn set_logical_pixels(&mut self, on: bool) {
289        self.logical_pixels = on;
290    }
291    /// Set current DPI scale and propagate to passes before rendering.
292    pub fn set_dpi_scale(&mut self, scale: f32) {
293        self.dpi_scale = if scale.is_finite() && scale > 0.0 {
294            scale
295        } else {
296            1.0
297        };
298    }
299    /// Set a global UI scale multiplier
300    pub fn set_ui_scale(&mut self, s: f32) {
301        self.ui_scale = if s.is_finite() { s } else { 1.0 };
302    }
303    /// Set an overlay callback for post-render passes
304    pub fn set_overlay(&mut self, callback: OverlayCallback) {
305        self.overlay = Some(callback);
306    }
307    /// Clear the overlay callback
308    pub fn clear_overlay(&mut self) {
309        self.overlay = None;
310    }
311
312    /// Set the GPU-side scroll offset (in logical pixels, typically negative).
313    /// This is written into the viewport uniform so the GPU applies the
314    /// scroll transform without rebuilding geometry.
315    pub fn set_scroll_offset(&mut self, offset: [f32; 2]) {
316        self.pass.set_scroll_offset(offset);
317    }
318
319    /// Get the current GPU-side scroll offset.
320    pub fn scroll_offset(&self) -> [f32; 2] {
321        self.pass.scroll_offset()
322    }
323
324    /// Access the cached frame data (if any) for scroll-only fast path decisions.
325    pub fn frame_cache(&self) -> Option<&CachedFrameData> {
326        self.frame_cache.as_ref()
327    }
328
329    /// Clear the frame cache (e.g., on resize or content change).
330    pub fn clear_frame_cache(&mut self) {
331        self.frame_cache = None;
332    }
333
334    /// Update the scroll position, generation, and hit index on the most recent
335    /// frame cache. Called by the renderer after `end_frame` to supply metadata
336    /// that `end_frame` doesn't have direct access to.
337    pub fn update_frame_cache_metadata(
338        &mut self,
339        scroll_at_build: (f32, f32),
340        generation: u64,
341        hit_index: HitIndex,
342    ) {
343        if let Some(ref mut cache) = self.frame_cache {
344            cache.scroll_at_build = scroll_at_build;
345            cache.generation_at_build = generation;
346            cache.hit_index = hit_index;
347        }
348    }
349
350    fn allocate_synthetic_external_texture_id(&mut self) -> ExternalTextureId {
351        let id = ExternalTextureId(self.next_synthetic_external_texture_id);
352        self.next_synthetic_external_texture_id =
353            self.next_synthetic_external_texture_id.wrapping_add(1);
354        id
355    }
356
357    fn opacity_group_z(commands: &[Command]) -> Option<i32> {
358        commands.iter().filter_map(Command::z_index).min()
359    }
360
361    fn collect_opacity_group(commands: &[Command], start_idx: usize) -> (Vec<Command>, usize) {
362        let mut depth = 1usize;
363        let mut i = start_idx;
364        let mut group = Vec::new();
365
366        while i < commands.len() {
367            match &commands[i] {
368                Command::PushOpacity(_) => {
369                    depth += 1;
370                    group.push(commands[i].clone());
371                }
372                Command::PopOpacity => {
373                    depth = depth.saturating_sub(1);
374                    if depth == 0 {
375                        return (group, i + 1);
376                    }
377                    group.push(Command::PopOpacity);
378                }
379                _ => group.push(commands[i].clone()),
380            }
381            i += 1;
382        }
383
384        (group, i)
385    }
386
387    fn build_glyph_draws_from_text_draws(
388        &self,
389        text_draws: &[jag_draw::ExtractedTextDraw],
390        provider: Option<&Arc<dyn jag_draw::TextProvider + Send + Sync>>,
391    ) -> Vec<(
392        [f32; 2],
393        jag_draw::RasterizedGlyph,
394        jag_draw::ColorLinPremul,
395        i32,
396    )> {
397        let Some(provider) = provider else {
398            return Vec::new();
399        };
400
401        let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
402            self.dpi_scale
403        } else {
404            1.0
405        };
406        let snap = |v: f32| -> f32 { (v * sf).round() / sf };
407
408        let mut glyph_draws = Vec::new();
409        for text_draw in text_draws {
410            let run = &text_draw.run;
411            let [a, b, c, d, e, f] = text_draw.transform.m;
412
413            let origin_x = a * run.pos[0] + c * run.pos[1] + e;
414            let origin_y = b * run.pos[0] + d * run.pos[1] + f;
415
416            let sx = (a * a + b * b).sqrt();
417            let sy = (c * c + d * d).sqrt();
418            let mut s = if sx.is_finite() && sy.is_finite() {
419                if sx > 0.0 && sy > 0.0 {
420                    (sx + sy) * 0.5
421                } else {
422                    sx.max(sy).max(1.0)
423                }
424            } else {
425                1.0
426            };
427            if !s.is_finite() || s <= 0.0 {
428                s = 1.0;
429            }
430
431            let logical_size = (run.size * s).max(1.0);
432            let physical_size = (logical_size * sf).max(1.0);
433            let run_for_provider = jag_draw::TextRun {
434                text: run.text.clone(),
435                pos: [0.0, 0.0],
436                size: physical_size,
437                color: run.color,
438                weight: run.weight,
439                style: run.style,
440                family: run.family.clone(),
441            };
442
443            let glyphs = jag_draw::rasterize_run_cached(provider.as_ref(), &run_for_provider);
444            for g in glyphs.iter() {
445                let mut origin = [origin_x + g.offset[0] / sf, origin_y + g.offset[1] / sf];
446                if logical_size <= 15.0 {
447                    origin[0] = snap(origin[0]);
448                    origin[1] = snap(origin[1]);
449                }
450                // Opacity groups are rendered into intermediate layers; LCD/subpixel text
451                // can ghost when composited again. Force grayscale AA in this path.
452                glyph_draws.push((
453                    origin,
454                    Self::grayscale_glyph_for_compositing(g),
455                    run.color,
456                    text_draw.z,
457                ));
458            }
459        }
460
461        glyph_draws
462    }
463
464    fn grayscale_glyph_for_compositing(
465        glyph: &jag_draw::RasterizedGlyph,
466    ) -> jag_draw::RasterizedGlyph {
467        use jag_draw::{GlyphMask, MaskFormat, SubpixelMask};
468
469        let mask = match &glyph.mask {
470            GlyphMask::Color(c) => GlyphMask::Color(c.clone()),
471            GlyphMask::Subpixel(m) => match m.format {
472                MaskFormat::Rgba8 => {
473                    let mut out = Vec::with_capacity(m.data.len());
474                    for px in m.data.chunks_exact(4) {
475                        let gray =
476                            ((u16::from(px[0]) + u16::from(px[1]) + u16::from(px[2])) / 3) as u8;
477                        out.extend_from_slice(&[gray, gray, gray, 0]);
478                    }
479                    GlyphMask::Subpixel(SubpixelMask {
480                        width: m.width,
481                        height: m.height,
482                        format: MaskFormat::Rgba8,
483                        data: out,
484                    })
485                }
486                MaskFormat::Rgba16 => {
487                    let mut out = Vec::with_capacity(m.data.len());
488                    for px in m.data.chunks_exact(8) {
489                        let r = u16::from_le_bytes([px[0], px[1]]);
490                        let g = u16::from_le_bytes([px[2], px[3]]);
491                        let b = u16::from_le_bytes([px[4], px[5]]);
492                        let gray = ((u32::from(r) + u32::from(g) + u32::from(b)) / 3) as u16;
493                        let gb = gray.to_le_bytes();
494                        out.extend_from_slice(&[gb[0], gb[1], gb[0], gb[1], gb[0], gb[1], 0, 0]);
495                    }
496                    GlyphMask::Subpixel(SubpixelMask {
497                        width: m.width,
498                        height: m.height,
499                        format: MaskFormat::Rgba16,
500                        data: out,
501                    })
502                }
503            },
504        };
505
506        jag_draw::RasterizedGlyph {
507            offset: glyph.offset,
508            mask,
509        }
510    }
511
512    fn render_opacity_group_layer(
513        &mut self,
514        viewport: Viewport,
515        commands: Vec<Command>,
516        text_provider: Option<&Arc<dyn jag_draw::TextProvider + Send + Sync>>,
517    ) -> Result<ExternalTextureId> {
518        let mut group_list = DisplayList { viewport, commands };
519        group_list.sort_by_z();
520
521        let group_scene =
522            jag_draw::upload_display_list_unified(&mut self.allocator, &self.queue, &group_list)?;
523        let group_glyphs =
524            self.build_glyph_draws_from_text_draws(&group_scene.text_draws, text_provider);
525
526        let mut group_svgs: Vec<_> = group_scene
527            .svg_draws
528            .iter()
529            .map(|draw| {
530                (
531                    crate::resolve_asset_path(&draw.path),
532                    draw.origin,
533                    draw.size,
534                    None,
535                    draw.z,
536                    Transform2D::identity(),
537                    None, // no clip for opacity group internals
538                )
539            })
540            .collect();
541        group_svgs.sort_by_key(|(_, _, _, _, z, _, _)| *z);
542
543        let mut group_images: Vec<(
544            std::path::PathBuf,
545            [f32; 2],
546            [f32; 2],
547            i32,
548            Option<jag_draw::Rect>,
549        )> = Vec::new();
550        for draw in &group_scene.image_draws {
551            let resolved_path = crate::resolve_asset_path(&draw.path);
552            if self
553                .pass
554                .load_image_to_view(&resolved_path, &self.queue)
555                .is_some()
556            {
557                group_images.push((resolved_path, draw.origin, draw.size, draw.z, None));
558            }
559        }
560        group_images.sort_by_key(|(_, _, _, z, _)| *z);
561
562        let width = viewport.width.max(1);
563        let height = viewport.height.max(1);
564        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
565            label: Some("opacity-group-layer"),
566            size: wgpu::Extent3d {
567                width,
568                height,
569                depth_or_array_layers: 1,
570            },
571            mip_level_count: 1,
572            sample_count: 1,
573            dimension: wgpu::TextureDimension::D2,
574            format: self.surface_format,
575            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
576            view_formats: &[],
577        });
578        let layer_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
579
580        let mut encoder = self
581            .device
582            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
583                label: Some("opacity-group-encoder"),
584            });
585        // Opacity groups render in their own coordinate space with no scroll.
586        let saved_scroll = self.pass.scroll_offset();
587        self.pass.set_scroll_offset([0.0, 0.0]);
588        self.pass.render_unified(
589            &mut encoder,
590            &mut self.allocator,
591            &layer_view,
592            width,
593            height,
594            &group_scene.gpu_scene,
595            &group_scene.transparent_gpu_scene,
596            &group_scene.transparent_batches,
597            &group_glyphs,
598            &group_svgs,
599            &group_images,
600            &group_scene.external_texture_draws,
601            wgpu::Color::TRANSPARENT,
602            true,
603            &self.queue,
604            false,
605        );
606        self.pass.set_scroll_offset(saved_scroll);
607        self.queue.submit(std::iter::once(encoder.finish()));
608
609        let tex_id = self.allocate_synthetic_external_texture_id();
610        self.pass.register_external_texture(tex_id, layer_view);
611        Ok(tex_id)
612    }
613
614    fn flatten_opacity_groups(
615        &mut self,
616        commands: &[Command],
617        viewport: Viewport,
618        text_provider: Option<&Arc<dyn jag_draw::TextProvider + Send + Sync>>,
619    ) -> Result<Vec<Command>> {
620        let mut out: Vec<Command> = Vec::new();
621        let mut i = 0usize;
622        while i < commands.len() {
623            match &commands[i] {
624                Command::PushOpacity(opacity) => {
625                    let (raw_group, next_i) = Self::collect_opacity_group(commands, i + 1);
626                    let flattened_group =
627                        self.flatten_opacity_groups(&raw_group, viewport, text_provider)?;
628
629                    // Preserve hit-only regions outside the composited layer.
630                    for cmd in flattened_group.iter() {
631                        match cmd {
632                            Command::HitRegionRect { .. }
633                            | Command::HitRegionRoundedRect { .. }
634                            | Command::HitRegionEllipse { .. } => out.push(cmd.clone()),
635                            _ => {}
636                        }
637                    }
638
639                    let layer_opacity = opacity.clamp(0.0, 1.0);
640                    if layer_opacity > 0.0
641                        && let Some(z) = Self::opacity_group_z(&flattened_group)
642                    {
643                        // DrawExternalTexture coordinates are interpreted in logical units
644                        // by PassManager when logical pixel mode is enabled.
645                        let logical_scale = jag_draw::logical_multiplier(
646                            self.logical_pixels,
647                            self.dpi_scale,
648                            self.ui_scale,
649                        );
650                        let logical_w = (viewport.width as f32) / logical_scale;
651                        let logical_h = (viewport.height as f32) / logical_scale;
652                        let tex_id = self.render_opacity_group_layer(
653                            viewport,
654                            flattened_group,
655                            text_provider,
656                        )?;
657                        out.push(Command::DrawExternalTexture {
658                            rect: Rect {
659                                x: 0.0,
660                                y: 0.0,
661                                w: logical_w,
662                                h: logical_h,
663                            },
664                            texture_id: tex_id,
665                            z,
666                            transform: Transform2D::identity(),
667                            opacity: layer_opacity,
668                            premultiplied: true,
669                        });
670                    }
671                    i = next_i;
672                }
673                Command::PopOpacity => {
674                    // Ignore unmatched pops.
675                    i += 1;
676                }
677                _ => {
678                    out.push(commands[i].clone());
679                    i += 1;
680                }
681            }
682        }
683        Ok(out)
684    }
685
686    /// Pre-allocate intermediate texture at the given size.
687    /// This should be called after surface reconfiguration to avoid jitter.
688    pub fn prepare_for_resize(&mut self, width: u32, height: u32) {
689        self.pass
690            .ensure_intermediate_texture(&mut self.allocator, width, height);
691    }
692
693    /// Begin a canvas frame of the given size (in pixels).
694    pub fn begin_frame(&self, width: u32, height: u32) -> Canvas {
695        let vp = Viewport { width, height };
696        Canvas {
697            viewport: vp,
698            painter: Painter::begin_frame(vp),
699            clear_color: None,
700            text_provider: None,
701            glyph_draws: Vec::new(),
702            svg_draws: Vec::new(),
703            image_draws: Vec::new(),
704            raw_image_draws: Vec::new(),
705            dpi_scale: self.dpi_scale,
706            clip_stack: vec![None],
707            overlay_draws: Vec::new(),
708            scrim_draws: Vec::new(),
709        }
710    }
711
712    /// Finish the frame by rendering accumulated commands to the provided surface texture.
713    pub fn end_frame(&mut self, frame: wgpu::SurfaceTexture, canvas: Canvas) -> Result<()> {
714        // Keep passes in sync with DPI/logical settings
715        self.pass.set_scale_factor(self.dpi_scale);
716        self.pass.set_logical_pixels(self.logical_pixels);
717        self.pass.set_ui_scale(self.ui_scale);
718
719        // Determine the render target: prefer intermediate when SMAA or Vello-style resizing is on.
720        let use_intermediate = self.enable_smaa || self.use_intermediate;
721
722        let text_provider = canvas.text_provider.clone();
723
724        // Build final display list from painter
725        let mut list = canvas.painter.finish();
726        let width = canvas.viewport.width.max(1);
727        let height = canvas.viewport.height.max(1);
728
729        if list
730            .commands
731            .iter()
732            .any(|cmd| matches!(cmd, Command::PushOpacity(_) | Command::PopOpacity))
733        {
734            let flattened =
735                self.flatten_opacity_groups(&list.commands, list.viewport, text_provider.as_ref())?;
736            list.commands = flattened;
737        }
738
739        // Sort display list by z-index to ensure proper layering
740        list.sort_by_z();
741
742        // Create target view
743        let view = frame
744            .texture
745            .create_view(&wgpu::TextureViewDescriptor::default());
746        let scene_view = if use_intermediate {
747            self.pass
748                .ensure_intermediate_texture(&mut self.allocator, width, height);
749            let scene_target = self
750                .pass
751                .intermediate_texture
752                .as_ref()
753                .expect("intermediate render target not allocated");
754            scene_target
755                .texture
756                .create_view(&wgpu::TextureViewDescriptor::default())
757        } else {
758            frame
759                .texture
760                .create_view(&wgpu::TextureViewDescriptor::default())
761        };
762
763        // Command encoder
764        let mut encoder = self
765            .device
766            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
767                label: Some("jag-surface-encoder"),
768            });
769
770        // Clear color or transparent
771        let clear = canvas.clear_color.unwrap_or(ColorLinPremul {
772            r: 0.0,
773            g: 0.0,
774            b: 0.0,
775            a: 0.0,
776        });
777        let clear_wgpu = wgpu::Color {
778            r: clear.r as f64,
779            g: clear.g as f64,
780            b: clear.b as f64,
781            a: clear.a as f64,
782        };
783
784        // Ensure depth texture is allocated for z-ordering (Phase 1 of depth buffer implementation)
785        self.pass
786            .ensure_depth_texture(&mut self.allocator, width, height);
787
788        // Extract unified scene data (solids + text/image/svg draws) from the display list.
789        let unified_scene =
790            jag_draw::upload_display_list_unified(&mut self.allocator, &self.queue, &list)?;
791
792        // Sort SVG draws by z-index and resolve paths for app bundle
793        let mut svg_draws: Vec<_> = canvas
794            .svg_draws
795            .iter()
796            .map(|(path, origin, max_size, style, z, transform, clip)| {
797                let resolved_path = crate::resolve_asset_path(path);
798                (
799                    resolved_path,
800                    *origin,
801                    *max_size,
802                    *style,
803                    *z,
804                    *transform,
805                    *clip,
806                )
807            })
808            .collect();
809        svg_draws.sort_by_key(|(_, _, _, _, z, _, _)| *z);
810
811        // Sort image draws by z-index and prepare simplified data (for unified pass)
812        let mut image_draws = canvas.image_draws.clone();
813        image_draws.sort_by_key(|(_, _, _, _, z, _, _)| *z);
814
815        // Convert image draws to simplified format (path, origin, size, z, clip)
816        // Apply transforms and fit calculations here. We synchronously load images
817        // via PassManager so that they appear on the very first frame, without
818        // requiring a scroll/resize to trigger a second redraw.
819        //
820        // NOTE: Origins in `canvas.image_draws` are already in logical coordinates;
821        // they will be scaled by PassManager via logical_pixels/dpi.
822        let mut prepared_images: Vec<(
823            std::path::PathBuf,
824            [f32; 2],
825            [f32; 2],
826            i32,
827            Option<jag_draw::Rect>,
828        )> = Vec::new();
829        for (path, origin, size, fit, z, transform, clip) in image_draws.iter() {
830            // Resolve path to check app bundle resources
831            let resolved_path = crate::resolve_asset_path(path);
832
833            // Synchronously load (or fetch from cache) to ensure the texture
834            // is available for this frame. This mirrors the demo-app unified
835            // path and avoids images only appearing after a later redraw.
836            if let Some((tex_view, img_w, img_h)) =
837                self.pass.load_image_to_view(&resolved_path, &self.queue)
838            {
839                drop(tex_view); // Only need dimensions here
840                let transformed_origin = apply_transform_to_point(*origin, *transform);
841                let (render_origin, render_size) = calculate_image_fit(
842                    transformed_origin,
843                    *size,
844                    img_w as f32,
845                    img_h as f32,
846                    *fit,
847                );
848                prepared_images.push((
849                    resolved_path.clone(),
850                    render_origin,
851                    render_size,
852                    *z,
853                    *clip,
854                ));
855            }
856        }
857
858        // Process raw image draws (e.g., WebView CEF pixels)
859        // Optimizations:
860        // 1. Always reuse textures - only recreate if size changes
861        // 2. Use BGRA format to match CEF native output (no CPU conversion)
862        // 3. Support dirty rect partial uploads
863        for (i, raw_draw) in canvas.raw_image_draws.iter().enumerate() {
864            if raw_draw.src_width == 0 || raw_draw.src_height == 0 {
865                continue;
866            }
867
868            // Use a fixed path for webview texture - reused across frames
869            let raw_path = std::path::PathBuf::from(format!("__webview_texture_{}__", i));
870
871            // If pixels are empty, reuse cached texture from previous frame
872            let has_new_pixels = !raw_draw.pixels.is_empty();
873
874            // Check if we need a new texture (only if size changed)
875            let need_new_texture =
876                if let Some((_, cached_w, cached_h)) = self.pass.try_get_image_view(&raw_path) {
877                    // Only recreate if dimensions changed - always reuse otherwise
878                    cached_w != raw_draw.src_width || cached_h != raw_draw.src_height
879                } else {
880                    true
881                };
882
883            // Create texture only when needed (first time or size change)
884            if need_new_texture && has_new_pixels {
885                // Create texture with BGRA format to match CEF's native output
886                // This eliminates CPU-side BGRA->RGBA conversion
887                let texture = self.device.create_texture(&wgpu::TextureDescriptor {
888                    label: Some("cef-webview-texture"),
889                    size: wgpu::Extent3d {
890                        width: raw_draw.src_width,
891                        height: raw_draw.src_height,
892                        depth_or_array_layers: 1,
893                    },
894                    mip_level_count: 1,
895                    sample_count: 1,
896                    dimension: wgpu::TextureDimension::D2,
897                    // BGRA format matches CEF native output - no conversion needed
898                    format: wgpu::TextureFormat::Bgra8UnormSrgb,
899                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
900                    view_formats: &[],
901                });
902
903                // Store in image cache for reuse
904                self.pass.store_loaded_image(
905                    &raw_path,
906                    Arc::new(texture),
907                    raw_draw.src_width,
908                    raw_draw.src_height,
909                );
910            }
911
912            // Upload pixels only when we have new data
913            if has_new_pixels {
914                if let Some((tex, _, _)) = self.pass.get_cached_texture(&raw_path) {
915                    // Always upload full frame - CEF provides complete buffer even for partial updates.
916                    // The dirty_rects are informational but the buffer is always complete.
917                    // This ensures no flickering from partial/stale data.
918                    self.queue.write_texture(
919                        wgpu::ImageCopyTexture {
920                            texture: &tex,
921                            mip_level: 0,
922                            origin: wgpu::Origin3d::ZERO,
923                            aspect: wgpu::TextureAspect::All,
924                        },
925                        &raw_draw.pixels,
926                        wgpu::ImageDataLayout {
927                            offset: 0,
928                            bytes_per_row: Some(raw_draw.src_width * 4),
929                            rows_per_image: Some(raw_draw.src_height),
930                        },
931                        wgpu::Extent3d {
932                            width: raw_draw.src_width,
933                            height: raw_draw.src_height,
934                            depth_or_array_layers: 1,
935                        },
936                    );
937                }
938            }
939
940            // Skip rendering if no cached texture exists (no pixels uploaded yet)
941            if self.pass.try_get_image_view(&raw_path).is_none() {
942                continue;
943            }
944
945            // Apply the canvas transform to the origin - same as regular images.
946            // The origin from draw_raw_image is in local (viewport) coordinates and
947            // needs to be transformed to screen coordinates.
948            let transformed_origin = apply_transform_to_point(raw_draw.origin, raw_draw.transform);
949
950            // Store the transformed rect for hit testing (accessible via get_last_raw_image_rect)
951            set_last_raw_image_rect(
952                transformed_origin[0],
953                transformed_origin[1],
954                raw_draw.dst_size[0],
955                raw_draw.dst_size[1],
956            );
957
958            prepared_images.push((
959                raw_path,
960                transformed_origin,
961                raw_draw.dst_size,
962                raw_draw.z,
963                raw_draw.clip,
964            ));
965        }
966
967        // Merge glyphs supplied explicitly via Canvas (draw_text_run/draw_text_direct)
968        // with text runs extracted from the display list (e.g., hyperlinks) for
969        // unified text rendering.
970        let mut glyph_draws = canvas.glyph_draws.clone();
971
972        if let Some(ref provider) = canvas.text_provider {
973            // Use the same snapping strategy as direct text paths so small
974            // text (e.g., 13–15px) lands cleanly on device pixels.
975            let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
976                self.dpi_scale
977            } else {
978                1.0
979            };
980            let snap = |v: f32| -> f32 { (v * sf).round() / sf };
981
982            for text_draw in &unified_scene.text_draws {
983                let run = &text_draw.run;
984                let [a, b, c, d, e, f] = text_draw.transform.m;
985
986                // Transform the run origin (baseline-left) into world coordinates.
987                let origin_x = a * run.pos[0] + c * run.pos[1] + e;
988                let origin_y = b * run.pos[0] + d * run.pos[1] + f;
989
990                // Infer uniform scale from the linear part of the transform so
991                // text respects any explicit scaling in the display list.
992                let sx = (a * a + b * b).sqrt();
993                let sy = (c * c + d * d).sqrt();
994                let mut s = if sx.is_finite() && sy.is_finite() {
995                    if sx > 0.0 && sy > 0.0 {
996                        (sx + sy) * 0.5
997                    } else {
998                        sx.max(sy).max(1.0)
999                    }
1000                } else {
1001                    1.0
1002                };
1003                if !s.is_finite() || s <= 0.0 {
1004                    s = 1.0;
1005                }
1006
1007                // Rasterize at *physical* pixel size (scaled by DPI) to match
1008                // the direct canvas text path. PassManager assumes glyph bitmaps
1009                // are at physical resolution and divides quad sizes by DPI.
1010                let logical_size = (run.size * s).max(1.0);
1011                let physical_size = (logical_size * sf).max(1.0);
1012                let run_for_provider = jag_draw::TextRun {
1013                    text: run.text.clone(),
1014                    pos: [0.0, 0.0],
1015                    size: physical_size,
1016                    color: run.color,
1017                    weight: run.weight,
1018                    style: run.style,
1019                    family: run.family.clone(),
1020                };
1021
1022                // Rasterize glyphs for this run and push into glyph_draws.
1023                // Provider offsets are in *physical* pixels (proportional to physical_size).
1024                // Convert back into logical coordinates so PassManager's DPI scaling
1025                // keeps geometry and text aligned.
1026                let glyphs = jag_draw::rasterize_run_cached(provider.as_ref(), &run_for_provider);
1027                for g in glyphs.iter() {
1028                    let mut origin = [origin_x + g.offset[0] / sf, origin_y + g.offset[1] / sf];
1029                    if logical_size <= 15.0 {
1030                        origin[0] = snap(origin[0]);
1031                        origin[1] = snap(origin[1]);
1032                    }
1033                    glyph_draws.push((origin, g.clone(), run.color, text_draw.z));
1034                }
1035            }
1036        }
1037
1038        // Unified solids + text/images/SVGs pass
1039        let preserve_surface = self.preserve_surface;
1040        let direct = self.direct || !use_intermediate;
1041        self.pass.render_unified(
1042            &mut encoder,
1043            &mut self.allocator,
1044            &scene_view,
1045            width,
1046            height,
1047            &unified_scene.gpu_scene,
1048            &unified_scene.transparent_gpu_scene,
1049            &unified_scene.transparent_batches,
1050            &glyph_draws,
1051            &svg_draws,
1052            &prepared_images,
1053            &unified_scene.external_texture_draws,
1054            clear_wgpu,
1055            direct,
1056            &self.queue,
1057            preserve_surface,
1058        );
1059
1060        // Cache the frame data for scroll-only fast path reuse.
1061        // The GPU buffers inside `unified_scene` persist as long as this
1062        // cache entry is alive, allowing `render_cached_frame` to re-render
1063        // without rebuilding the display list or re-uploading geometry.
1064        self.frame_cache = Some(CachedFrameData {
1065            gpu_scene: unified_scene.gpu_scene,
1066            transparent_gpu_scene: unified_scene.transparent_gpu_scene,
1067            transparent_batches: unified_scene.transparent_batches,
1068            glyph_draws,
1069            svg_draws,
1070            image_draws: prepared_images,
1071            external_texture_draws: unified_scene.external_texture_draws,
1072            clear: clear_wgpu,
1073            direct,
1074            width,
1075            height,
1076            scroll_at_build: (0.0, 0.0), // Set by caller via set_cache_scroll_at_build
1077            generation_at_build: 0,      // Set by caller
1078            viewport_size: (width, height),
1079            hit_index: HitIndex::default(), // Set by caller
1080        });
1081
1082        // Render scrims; support both simple rects and stencil cutouts.
1083        for scrim in &canvas.scrim_draws {
1084            match scrim {
1085                crate::ScrimDraw::Rect(rect, color) => {
1086                    self.pass.draw_scrim_rect(
1087                        &mut encoder,
1088                        &scene_view,
1089                        width,
1090                        height,
1091                        *rect,
1092                        *color,
1093                        &self.queue,
1094                    );
1095                }
1096                crate::ScrimDraw::Cutout { hole, color } => {
1097                    self.pass.draw_scrim_with_cutout(
1098                        &mut encoder,
1099                        &mut self.allocator,
1100                        &scene_view,
1101                        width,
1102                        height,
1103                        *hole,
1104                        *color,
1105                        &self.queue,
1106                    );
1107                }
1108            }
1109        }
1110
1111        // Render overlay rectangles (modal scrims) without depth testing.
1112        // These blend over the entire scene without blocking text.
1113        for (rect, color) in &canvas.overlay_draws {
1114            self.pass.draw_overlay_rect(
1115                &mut encoder,
1116                &scene_view,
1117                width,
1118                height,
1119                *rect,
1120                *color,
1121                &self.queue,
1122            );
1123        }
1124
1125        // Call overlay callback last so overlays (e.g., devtools, debug UI)
1126        // are guaranteed to draw above all other content.
1127        if let Some(ref mut overlay_fn) = self.overlay {
1128            overlay_fn(
1129                &mut self.pass,
1130                &mut encoder,
1131                &scene_view,
1132                &self.queue,
1133                width,
1134                height,
1135            );
1136        }
1137
1138        // Resolve to the swapchain: SMAA when enabled, otherwise a nearest-neighbor blit for sharper text.
1139        if use_intermediate {
1140            if self.enable_smaa {
1141                self.pass.apply_smaa(
1142                    &mut encoder,
1143                    &mut self.allocator,
1144                    &scene_view,
1145                    &view,
1146                    width,
1147                    height,
1148                    &self.queue,
1149                );
1150            } else {
1151                self.pass.blit_to_surface(&mut encoder, &view);
1152            }
1153        }
1154
1155        // Submit and present
1156        let cb = encoder.finish();
1157        self.queue.submit(std::iter::once(cb));
1158        frame.present();
1159        Ok(())
1160    }
1161
1162    /// Re-render using the internally-cached frame data with an updated GPU
1163    /// scroll offset. This is the scroll-only fast path.
1164    ///
1165    /// `scroll_delta` is the negated difference between the current scroll
1166    /// position and the position when the frame was originally built.
1167    pub fn render_cached_frame_from_internal(
1168        &mut self,
1169        frame: wgpu::SurfaceTexture,
1170        scroll_delta: [f32; 2],
1171    ) -> Result<()> {
1172        let cache = self
1173            .frame_cache
1174            .as_ref()
1175            .ok_or_else(|| anyhow::anyhow!("no cached frame data"))?;
1176
1177        self.pass.set_scale_factor(self.dpi_scale);
1178        self.pass.set_logical_pixels(self.logical_pixels);
1179        self.pass.set_ui_scale(self.ui_scale);
1180
1181        let use_intermediate = self.enable_smaa || self.use_intermediate;
1182        let width = cache.width;
1183        let height = cache.height;
1184        let clear = cache.clear;
1185        let direct = cache.direct;
1186
1187        // Set the scroll delta as GPU uniform
1188        self.pass.set_scroll_offset(scroll_delta);
1189
1190        let view = frame
1191            .texture
1192            .create_view(&wgpu::TextureViewDescriptor::default());
1193        let scene_view = if use_intermediate {
1194            self.pass
1195                .ensure_intermediate_texture(&mut self.allocator, width, height);
1196            let scene_target = self
1197                .pass
1198                .intermediate_texture
1199                .as_ref()
1200                .expect("intermediate render target not allocated");
1201            scene_target
1202                .texture
1203                .create_view(&wgpu::TextureViewDescriptor::default())
1204        } else {
1205            frame
1206                .texture
1207                .create_view(&wgpu::TextureViewDescriptor::default())
1208        };
1209
1210        let mut encoder = self
1211            .device
1212            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1213                label: Some("jag-surface-cached-encoder"),
1214            });
1215
1216        self.pass
1217            .ensure_depth_texture(&mut self.allocator, width, height);
1218
1219        // Re-borrow the cache after the mutable borrows above are done.
1220        let cache = self.frame_cache.as_ref().unwrap();
1221        self.pass.render_unified(
1222            &mut encoder,
1223            &mut self.allocator,
1224            &scene_view,
1225            width,
1226            height,
1227            &cache.gpu_scene,
1228            &cache.transparent_gpu_scene,
1229            &cache.transparent_batches,
1230            &cache.glyph_draws,
1231            &cache.svg_draws,
1232            &cache.image_draws,
1233            &cache.external_texture_draws,
1234            clear,
1235            direct,
1236            &self.queue,
1237            false, // don't preserve surface
1238        );
1239
1240        // Resolve to swapchain
1241        if use_intermediate {
1242            if self.enable_smaa {
1243                self.pass.apply_smaa(
1244                    &mut encoder,
1245                    &mut self.allocator,
1246                    &scene_view,
1247                    &view,
1248                    width,
1249                    height,
1250                    &self.queue,
1251                );
1252            } else {
1253                self.pass.blit_to_surface(&mut encoder, &view);
1254            }
1255        }
1256
1257        let cb = encoder.finish();
1258        self.queue.submit(std::iter::once(cb));
1259        frame.present();
1260
1261        // Reset scroll offset for subsequent full rebuilds
1262        self.pass.set_scroll_offset([0.0, 0.0]);
1263
1264        Ok(())
1265    }
1266
1267    /// Finish a frame by rendering to an offscreen texture and returning the
1268    /// pixel data as an RGBA byte vector. This is the headless equivalent of
1269    /// [`end_frame`] and does not require a window or surface.
1270    ///
1271    /// Returns `(width, height, pixels)` where `pixels` is tightly-packed
1272    /// RGBA with 4 bytes per pixel (`width * height * 4` total).
1273    pub fn end_frame_headless(&mut self, canvas: Canvas) -> Result<(u32, u32, Vec<u8>)> {
1274        // Keep passes in sync with DPI/logical settings
1275        self.pass.set_scale_factor(self.dpi_scale);
1276        self.pass.set_logical_pixels(self.logical_pixels);
1277        self.pass.set_ui_scale(self.ui_scale);
1278
1279        // Build final display list from painter
1280        let text_provider = canvas.text_provider.clone();
1281
1282        // Build final display list from painter
1283        let mut list = canvas.painter.finish();
1284        let width = canvas.viewport.width.max(1);
1285        let height = canvas.viewport.height.max(1);
1286
1287        if list
1288            .commands
1289            .iter()
1290            .any(|cmd| matches!(cmd, Command::PushOpacity(_) | Command::PopOpacity))
1291        {
1292            let flattened =
1293                self.flatten_opacity_groups(&list.commands, list.viewport, text_provider.as_ref())?;
1294            list.commands = flattened;
1295        }
1296
1297        // Sort display list by z-index to ensure proper layering
1298        list.sort_by_z();
1299
1300        // Clear color or transparent
1301        let clear = canvas.clear_color.unwrap_or(ColorLinPremul {
1302            r: 0.0,
1303            g: 0.0,
1304            b: 0.0,
1305            a: 0.0,
1306        });
1307        let clear_wgpu = wgpu::Color {
1308            r: clear.r as f64,
1309            g: clear.g as f64,
1310            b: clear.b as f64,
1311            a: clear.a as f64,
1312        };
1313
1314        // Ensure depth texture for z-ordering
1315        self.pass
1316            .ensure_depth_texture(&mut self.allocator, width, height);
1317
1318        // Extract unified scene data
1319        let unified_scene =
1320            jag_draw::upload_display_list_unified(&mut self.allocator, &self.queue, &list)?;
1321
1322        // Process text draws from display list via text provider
1323        let mut glyph_draws = canvas.glyph_draws.clone();
1324        if let Some(ref provider) = canvas.text_provider {
1325            let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
1326                self.dpi_scale
1327            } else {
1328                1.0
1329            };
1330            let snap = |v: f32| -> f32 { (v * sf).round() / sf };
1331
1332            for text_draw in &unified_scene.text_draws {
1333                let run = &text_draw.run;
1334                let [a, b, c, d, e, f] = text_draw.transform.m;
1335
1336                let origin_x = a * run.pos[0] + c * run.pos[1] + e;
1337                let origin_y = b * run.pos[0] + d * run.pos[1] + f;
1338
1339                let sx = (a * a + b * b).sqrt();
1340                let sy = (c * c + d * d).sqrt();
1341                let mut s = if sx.is_finite() && sy.is_finite() {
1342                    if sx > 0.0 && sy > 0.0 {
1343                        (sx + sy) * 0.5
1344                    } else {
1345                        sx.max(sy).max(1.0)
1346                    }
1347                } else {
1348                    1.0
1349                };
1350                if !s.is_finite() || s <= 0.0 {
1351                    s = 1.0;
1352                }
1353
1354                let logical_size = (run.size * s).max(1.0);
1355                let physical_size = (logical_size * sf).max(1.0);
1356                let run_for_provider = jag_draw::TextRun {
1357                    text: run.text.clone(),
1358                    pos: [0.0, 0.0],
1359                    size: physical_size,
1360                    color: run.color,
1361                    weight: run.weight,
1362                    style: run.style,
1363                    family: run.family.clone(),
1364                };
1365
1366                let glyphs = jag_draw::rasterize_run_cached(provider.as_ref(), &run_for_provider);
1367                for g in glyphs.iter() {
1368                    let mut origin = [origin_x + g.offset[0] / sf, origin_y + g.offset[1] / sf];
1369                    if logical_size <= 15.0 {
1370                        origin[0] = snap(origin[0]);
1371                        origin[1] = snap(origin[1]);
1372                    }
1373                    glyph_draws.push((origin, g.clone(), run.color, text_draw.z));
1374                }
1375            }
1376        }
1377
1378        // Sort and resolve SVG draws
1379        let mut svg_draws: Vec<_> = canvas
1380            .svg_draws
1381            .iter()
1382            .map(|(path, origin, max_size, style, z, transform, clip)| {
1383                let resolved_path = crate::resolve_asset_path(path);
1384                (
1385                    resolved_path,
1386                    *origin,
1387                    *max_size,
1388                    *style,
1389                    *z,
1390                    *transform,
1391                    *clip,
1392                )
1393            })
1394            .collect();
1395        svg_draws.sort_by_key(|(_, _, _, _, z, _, _)| *z);
1396
1397        // Sort and prepare image draws
1398        let mut image_draws = canvas.image_draws.clone();
1399        image_draws.sort_by_key(|(_, _, _, _, z, _, _)| *z);
1400
1401        let mut prepared_images: Vec<(
1402            std::path::PathBuf,
1403            [f32; 2],
1404            [f32; 2],
1405            i32,
1406            Option<jag_draw::Rect>,
1407        )> = Vec::new();
1408        for (path, origin, size, fit, z, transform, clip) in image_draws.iter() {
1409            let resolved_path = crate::resolve_asset_path(path);
1410            if let Some((tex_view, img_w, img_h)) =
1411                self.pass.load_image_to_view(&resolved_path, &self.queue)
1412            {
1413                drop(tex_view);
1414                let transformed_origin = apply_transform_to_point(*origin, *transform);
1415                let (render_origin, render_size) = calculate_image_fit(
1416                    transformed_origin,
1417                    *size,
1418                    img_w as f32,
1419                    img_h as f32,
1420                    *fit,
1421                );
1422                prepared_images.push((
1423                    resolved_path.clone(),
1424                    render_origin,
1425                    render_size,
1426                    *z,
1427                    *clip,
1428                ));
1429            }
1430        }
1431
1432        // Create offscreen render target
1433        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1434            label: Some("headless-render-target"),
1435            size: wgpu::Extent3d {
1436                width,
1437                height,
1438                depth_or_array_layers: 1,
1439            },
1440            mip_level_count: 1,
1441            sample_count: 1,
1442            dimension: wgpu::TextureDimension::D2,
1443            format: self.surface_format,
1444            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1445            view_formats: &[],
1446        });
1447        let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1448
1449        // Command encoder
1450        let mut encoder = self
1451            .device
1452            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1453                label: Some("headless-encoder"),
1454            });
1455
1456        // Render unified pass directly to the offscreen texture
1457        self.pass.render_unified(
1458            &mut encoder,
1459            &mut self.allocator,
1460            &texture_view,
1461            width,
1462            height,
1463            &unified_scene.gpu_scene,
1464            &unified_scene.transparent_gpu_scene,
1465            &unified_scene.transparent_batches,
1466            &glyph_draws,
1467            &svg_draws,
1468            &prepared_images,
1469            &unified_scene.external_texture_draws,
1470            clear_wgpu,
1471            true, // direct rendering (no intermediate)
1472            &self.queue,
1473            false, // don't preserve surface
1474        );
1475
1476        // Copy rendered texture to a CPU-readable buffer
1477        let bytes_per_pixel = 4u32;
1478        let unpadded_bytes_per_row = width * bytes_per_pixel;
1479        let padded_bytes_per_row = (unpadded_bytes_per_row + 255) & !255;
1480        let buffer_size = (padded_bytes_per_row * height) as u64;
1481
1482        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
1483            label: Some("headless-readback"),
1484            size: buffer_size,
1485            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1486            mapped_at_creation: false,
1487        });
1488
1489        encoder.copy_texture_to_buffer(
1490            wgpu::ImageCopyTexture {
1491                texture: &texture,
1492                mip_level: 0,
1493                origin: wgpu::Origin3d::ZERO,
1494                aspect: wgpu::TextureAspect::All,
1495            },
1496            wgpu::ImageCopyBuffer {
1497                buffer: &readback,
1498                layout: wgpu::ImageDataLayout {
1499                    offset: 0,
1500                    bytes_per_row: Some(padded_bytes_per_row),
1501                    rows_per_image: Some(height),
1502                },
1503            },
1504            wgpu::Extent3d {
1505                width,
1506                height,
1507                depth_or_array_layers: 1,
1508            },
1509        );
1510
1511        // Submit and wait
1512        self.queue.submit(std::iter::once(encoder.finish()));
1513
1514        let (tx, rx) = std::sync::mpsc::channel();
1515        readback
1516            .slice(..)
1517            .map_async(wgpu::MapMode::Read, move |result| {
1518                result.expect("failed to map readback buffer");
1519                tx.send(()).expect("failed to signal readback");
1520            });
1521        self.device.poll(wgpu::Maintain::Wait);
1522        rx.recv()
1523            .map_err(|e| anyhow::anyhow!("readback recv: {}", e))?;
1524
1525        // Extract tightly-packed RGBA pixels (strip row padding)
1526        let mapped = readback.slice(..).get_mapped_range();
1527        let mut pixels = Vec::with_capacity((width * height * bytes_per_pixel) as usize);
1528        for row in 0..height {
1529            let start = (row * padded_bytes_per_row) as usize;
1530            let end = start + (width * bytes_per_pixel) as usize;
1531            pixels.extend_from_slice(&mapped[start..end]);
1532        }
1533        drop(mapped);
1534        readback.unmap();
1535
1536        Ok((width, height, pixels))
1537    }
1538}