Skip to main content

truce_gpu/
backend.rs

1//! GPU rendering backend using wgpu.
2//!
3//! Renders via Metal (macOS), DX12 (Windows), or Vulkan (Linux).
4//! Uses immediate-mode geometry: each frame rebuilds the vertex buffer
5//! from `RenderBackend` draw calls, then flushes in `present()`.
6
7use std::collections::HashMap;
8#[cfg(target_os = "macos")]
9use std::ffi::c_void;
10use std::sync::Arc;
11
12use bytemuck::{Pod, Zeroable};
13use lyon_tessellation::geom::point;
14use lyon_tessellation::path::Path;
15use lyon_tessellation::{
16    BuffersBuilder, FillOptions, FillTessellator, FillVertex, StrokeOptions, StrokeTessellator,
17    StrokeVertex, VertexBuffers,
18};
19use wgpu::util::DeviceExt;
20
21use truce_core::cast::len_u32;
22use truce_gui_types::render::{ImageId, RenderBackend};
23use truce_gui_types::theme::Color;
24
25// ---------------------------------------------------------------------------
26// Vertex format
27// ---------------------------------------------------------------------------
28
29#[repr(C)]
30#[derive(Copy, Clone, Pod, Zeroable)]
31struct Vertex {
32    position: [f32; 2],
33    color: [f32; 4],
34    uv: [f32; 2],
35    /// 0.0 = solid color; 1.0 = glyph atlas (R8, .r is alpha);
36    /// 2.0 = RGBA image (tex * color, both premultiplied).
37    tex_mode: f32,
38    _pad: f32,
39}
40
41impl Vertex {
42    fn solid(x: f32, y: f32, color: [f32; 4]) -> Self {
43        Self {
44            position: [x, y],
45            color,
46            uv: [0.0, 0.0],
47            tex_mode: 0.0,
48            _pad: 0.0,
49        }
50    }
51
52    fn glyph(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
53        Self {
54            position: [x, y],
55            color,
56            uv: [u, v],
57            tex_mode: 1.0,
58            _pad: 0.0,
59        }
60    }
61
62    fn image(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
63        Self {
64            position: [x, y],
65            color,
66            uv: [u, v],
67            tex_mode: 2.0,
68            _pad: 0.0,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Glyph atlas
75// ---------------------------------------------------------------------------
76
77const ATLAS_SIZE: u32 = 512;
78
79struct GlyphUV {
80    u0: f32,
81    v0: f32,
82    u1: f32,
83    v1: f32,
84    advance: f32,
85    width: f32,
86    height: f32,
87    y_offset: f32,
88}
89
90struct GlyphAtlas {
91    /// Shelf-packing state.
92    shelf_y: u32,
93    shelf_h: u32,
94    cursor_x: u32,
95    /// Cached glyph UVs keyed by (char, `size_tenths`).
96    glyphs: HashMap<(char, u32), GlyphUV>,
97    /// Pending pixel uploads: (x, y, w, h, data).
98    pending: Vec<(u32, u32, u32, u32, Vec<u8>)>,
99    /// Set when `ensure_glyph` couldn't fit a new glyph. The next call to
100    /// `WgpuBackend::clear` evicts the cache so subsequent frames can
101    /// re-rasterize from scratch - never mid-frame, which would invalidate
102    /// UVs the current frame's vertex buffer already references.
103    overflow_pending: bool,
104}
105
106impl GlyphAtlas {
107    fn new() -> Self {
108        Self {
109            shelf_y: 0,
110            shelf_h: 0,
111            cursor_x: 0,
112            glyphs: HashMap::new(),
113            pending: Vec::new(),
114            overflow_pending: false,
115        }
116    }
117
118    fn clear(&mut self) {
119        self.shelf_y = 0;
120        self.shelf_h = 0;
121        self.cursor_x = 0;
122        self.glyphs.clear();
123        self.overflow_pending = false;
124    }
125
126    /// Try to place a glyph in the atlas. On overflow, sets
127    /// `overflow_pending` and returns without inserting; caller must
128    /// tolerate a missing entry for the rest of this frame. Subsequent
129    /// frames clear the atlas at frame start and re-rasterize.
130    // Quantized cache key - `(size * 10.0) as u32` deliberately
131    // truncates to one decimal place for HashMap stability. Atlas
132    // dimensions and glyph metrics fit comfortably in f32.
133    #[allow(
134        clippy::cast_possible_truncation,
135        clippy::cast_sign_loss,
136        clippy::cast_precision_loss
137    )]
138    fn ensure_glyph(&mut self, font: &fontdue::Font, ch: char, size: f32) {
139        let key = (ch, (size * 10.0) as u32);
140        if self.glyphs.contains_key(&key) {
141            return;
142        }
143        let (metrics, bitmap) = font.rasterize(ch, size);
144        let gw = len_u32(metrics.width);
145        let gh = len_u32(metrics.height);
146
147        // Shelf-pack: does it fit on the current shelf?
148        if self.cursor_x + gw > ATLAS_SIZE {
149            self.shelf_y += self.shelf_h;
150            self.shelf_h = 0;
151            self.cursor_x = 0;
152        }
153        if self.shelf_y + gh > ATLAS_SIZE {
154            // Atlas full. Calling self.clear() here would wipe entries
155            // the current frame's vertex buffer still references and
156            // evict glyphs that earlier draw_text iterations expect to
157            // look up - at best wrong UVs, at worst a HashMap lookup
158            // panic. Defer the clear to the next frame boundary.
159            self.overflow_pending = true;
160            return;
161        }
162
163        let x = self.cursor_x;
164        let y = self.shelf_y;
165        self.cursor_x += gw;
166        self.shelf_h = self.shelf_h.max(gh);
167
168        let u0 = x as f32 / ATLAS_SIZE as f32;
169        let v0 = y as f32 / ATLAS_SIZE as f32;
170        let u1 = (x + gw) as f32 / ATLAS_SIZE as f32;
171        let v1 = (y + gh) as f32 / ATLAS_SIZE as f32;
172
173        self.pending.push((x, y, gw, gh, bitmap));
174
175        self.glyphs.insert(
176            key,
177            GlyphUV {
178                u0,
179                v0,
180                u1,
181                v1,
182                advance: metrics.advance_width,
183                width: gw as f32,
184                height: gh as f32,
185                y_offset: metrics.ymin as f32,
186            },
187        );
188    }
189}
190
191// ---------------------------------------------------------------------------
192// WGSL shader
193// ---------------------------------------------------------------------------
194
195const SHADER_SRC: &str = r"
196struct Viewport {
197    transform: mat4x4<f32>,
198};
199@group(0) @binding(0) var<uniform> viewport: Viewport;
200
201// At group 1 slot 0 we bind either the R8 glyph atlas (tex_mode == 1.0)
202// or an RGBA image (tex_mode == 2.0). For solid draws (tex_mode == 0.0)
203// the texture is not sampled; any compatible binding works.
204@group(1) @binding(0) var main_tex: texture_2d<f32>;
205@group(1) @binding(1) var main_samp: sampler;
206
207struct VsIn {
208    @location(0) position: vec2<f32>,
209    @location(1) color: vec4<f32>,
210    @location(2) uv: vec2<f32>,
211    @location(3) tex_mode: f32,
212};
213
214struct VsOut {
215    @builtin(position) clip_pos: vec4<f32>,
216    @location(0) color: vec4<f32>,
217    @location(1) uv: vec2<f32>,
218    @location(2) tex_mode: f32,
219};
220
221@vertex
222fn vs_main(in: VsIn) -> VsOut {
223    var out: VsOut;
224    out.clip_pos = viewport.transform * vec4<f32>(in.position, 0.0, 1.0);
225    out.color = in.color;
226    out.uv = in.uv;
227    out.tex_mode = in.tex_mode;
228    return out;
229}
230
231@fragment
232fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
233    let tex = textureSample(main_tex, main_samp, in.uv);
234    if (in.tex_mode > 1.5) {
235        // Image: RGBA texture tinted by vertex color. Both sides are
236        // treated as premultiplied; output is premultiplied.
237        return tex * in.color;
238    }
239    // Glyph (tex_mode == 1) uses .r as coverage; solid (tex_mode == 0)
240    // bypasses the sample. mix(1.0, tex.r, tex_mode) handles both.
241    let alpha = mix(1.0, tex.r, in.tex_mode);
242    return vec4<f32>(in.color.rgb * in.color.a * alpha, in.color.a * alpha);
243}
244";
245
246// ---------------------------------------------------------------------------
247// WgpuBackend
248// ---------------------------------------------------------------------------
249
250/// One image registered via `register_image`. Owns its wgpu texture
251/// (kept alive so the bind group's view stays valid) and the bind group.
252struct ImageEntry {
253    _texture: wgpu::Texture,
254    bind_group: wgpu::BindGroup,
255}
256
257/// A contiguous run of indices that share a single bind group.
258///
259/// `image` is `None` for primitives and glyphs (which use the atlas bind
260/// group) and `Some(id)` for RGBA image draws. Batches are closed and a
261/// new one started whenever the target bind group changes.
262#[derive(Clone, Copy)]
263struct DrawBatch {
264    index_start: u32,
265    image: Option<ImageId>,
266}
267
268/// GPU-based rendering backend.
269///
270/// Creates a wgpu device and surface from a platform-provided Metal layer
271/// (macOS) or window handle. Implements `RenderBackend` by accumulating
272/// geometry per frame, then flushing it in `present()`.
273pub struct WgpuBackend {
274    device: Arc<wgpu::Device>,
275    queue: Arc<wgpu::Queue>,
276    /// None for headless mode (snapshot testing) or when using the
277    /// standalone `new()` constructor (caller owns the surface). When
278    /// present, `present()` renders to the surface frame.
279    surface: Option<wgpu::Surface<'static>>,
280    surface_config: Option<wgpu::SurfaceConfiguration>,
281    pipeline: wgpu::RenderPipeline,
282    /// Format of the eventual color target. Used to (re)build the MSAA
283    /// texture on resize / `begin_frame` size changes.
284    target_format: wgpu::TextureFormat,
285    msaa_texture: wgpu::TextureView,
286    /// Current physical dimensions of the MSAA texture. `begin_frame`
287    /// rebuilds the texture if these no longer match the target view.
288    msaa_width: u32,
289    msaa_height: u32,
290    vertices: Vec<Vertex>,
291    indices: Vec<u32>,
292    /// Ordered list of bind-group switches within the current frame. Always
293    /// starts with one batch referencing the atlas; additional entries are
294    /// appended when `draw_image` needs to switch to an image bind group.
295    batches: Vec<DrawBatch>,
296    glyph_atlas: GlyphAtlas,
297    font: fontdue::Font,
298    atlas_texture: wgpu::Texture,
299    atlas_bind_group: wgpu::BindGroup,
300    /// Layout shared between the atlas bind group and every per-image
301    /// bind group (same `texture2d<f32>` + sampler layout).
302    tex_bind_group_layout: wgpu::BindGroupLayout,
303    /// Shared linear sampler used for both the glyph atlas and images.
304    sampler: wgpu::Sampler,
305    /// Registered images indexed by `ImageId.0`. `None` = free slot.
306    images: Vec<Option<ImageEntry>>,
307    viewport_buffer: wgpu::Buffer,
308    viewport_bind_group: wgpu::BindGroup,
309    /// Pending clear request for the next render pass. `Some(c)` means
310    /// the next `finish()` clears the target to `c`; `None` means it
311    /// loads existing contents (the common case when widgets overlay a
312    /// custom render). Set by [`RenderBackend::clear`] and consumed by
313    /// `finish()` / `present()`.
314    clear_color: Option<wgpu::Color>,
315    /// Fallback clear color for the present path (which can't `Load` -
316    /// the swap-chain texture would surface stale prior-frame content).
317    /// Used when `clear_color` is `None`. The Metal layer path defaults
318    /// to `TRANSPARENT` so the host's compositor sees through; other
319    /// backends default to `BLACK`.
320    present_clear_default: wgpu::Color,
321    width: u32,
322    height: u32,
323    /// Scale factor: logical points × scale = physical pixels.
324    scale: f32,
325}
326
327fn ortho_matrix(w: f32, h: f32) -> [[f32; 4]; 4] {
328    [
329        [2.0 / w, 0.0, 0.0, 0.0],
330        [0.0, -2.0 / h, 0.0, 0.0],
331        [0.0, 0.0, 1.0, 0.0],
332        [-1.0, 1.0, 0.0, 1.0],
333    ]
334}
335
336impl WgpuBackend {
337    /// Create a GPU backend from a pre-created wgpu surface.
338    ///
339    /// `logical_w` and `logical_h` are in logical points. `scale` is the
340    /// display scale factor (2.0 on Retina). The surface is configured at
341    /// `logical × scale` physical pixels.
342    ///
343    /// # Panics
344    ///
345    /// Panics if the embedded font fails to parse (a bug in the
346    /// bundled font asset, never user input).
347    // Surface dimensions in pixels stay below 2^23, well within f32.
348    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
349    pub fn from_surface(
350        instance: &wgpu::Instance,
351        surface: wgpu::Surface<'static>,
352        logical_w: u32,
353        logical_h: u32,
354        scale: f32,
355    ) -> Option<Self> {
356        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
357            power_preference: wgpu::PowerPreference::HighPerformance,
358            compatible_surface: Some(&surface),
359            force_fallback_adapter: false,
360        }))
361        .ok()?;
362
363        // Request what the adapter actually supports rather than the
364        // fixed `downlevel_defaults` cap (2048 max texture dim). On a
365        // desktop GPU that's typically 8192-16384, which is needed for
366        // any editor whose Retina-physical canvas exceeds 2048px on
367        // either axis (the GUI Zoo's tall layouts). The defensive
368        // clamp below still guards against requesting a surface
369        // larger than whatever the device ended up granting.
370        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
371            label: Some("truce-gpu"),
372            required_features: wgpu::Features::empty(),
373            required_limits: adapter.limits(),
374            experimental_features: wgpu::ExperimentalFeatures::default(),
375            memory_hints: wgpu::MemoryHints::Performance,
376            trace: wgpu::Trace::Off,
377        }))
378        .ok()?;
379        let device = Arc::new(device);
380        let queue = Arc::new(queue);
381
382        let max_dim = device.limits().max_texture_dimension_2d.max(1);
383        let width = truce_gui_types::to_physical_px(logical_w, f64::from(scale)).clamp(1, max_dim);
384        let height = truce_gui_types::to_physical_px(logical_h, f64::from(scale)).clamp(1, max_dim);
385
386        // Prefer `Rgba8Unorm` so the surface format matches
387        // `read_pixels` and the headless screenshot path; fall back to
388        // any non-sRGB format the surface advertises, then to whatever
389        // the surface lists first. Keeping the format aligned across
390        // windowed and headless paths means the same shader-side
391        // gamma/blend assumptions hold.
392        let surface_caps = surface.get_capabilities(&adapter);
393        let surface_format = surface_caps
394            .formats
395            .iter()
396            .find(|f| **f == wgpu::TextureFormat::Rgba8Unorm)
397            .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()))
398            .copied()
399            .unwrap_or(surface_caps.formats[0]);
400
401        let surface_config = wgpu::SurfaceConfiguration {
402            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
403            format: surface_format,
404            width,
405            height,
406            // Windows: `on_frame` runs on the host's GUI thread; a Fifo
407            // (AutoVsync) present blocks that thread when the child-window
408            // swapchain backs up, freezing the host (REAPER) and risking a
409            // GPU-watchdog (TDR) hang. Non-blocking present elsewhere keeps
410            // vsync.
411            #[cfg(target_os = "windows")]
412            present_mode: wgpu::PresentMode::AutoNoVsync,
413            #[cfg(not(target_os = "windows"))]
414            present_mode: wgpu::PresentMode::AutoVsync,
415            desired_maximum_frame_latency: 2,
416            alpha_mode: wgpu::CompositeAlphaMode::Auto,
417            view_formats: vec![],
418        };
419        surface.configure(&device, &surface_config);
420
421        // MSAA texture
422        let msaa_texture = Self::create_msaa_texture(&device, &surface_config);
423
424        // Shader
425        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
426            label: Some("truce-gpu-shader"),
427            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
428        });
429
430        // Viewport uniform
431        let matrix = ortho_matrix(width as f32, height as f32);
432        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
433            label: Some("viewport"),
434            contents: bytemuck::cast_slice(&matrix),
435            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
436        });
437
438        let viewport_bind_group_layout =
439            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
440                label: Some("viewport-layout"),
441                entries: &[wgpu::BindGroupLayoutEntry {
442                    binding: 0,
443                    visibility: wgpu::ShaderStages::VERTEX,
444                    ty: wgpu::BindingType::Buffer {
445                        ty: wgpu::BufferBindingType::Uniform,
446                        has_dynamic_offset: false,
447                        min_binding_size: None,
448                    },
449                    count: None,
450                }],
451            });
452
453        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
454            label: Some("viewport-bg"),
455            layout: &viewport_bind_group_layout,
456            entries: &[wgpu::BindGroupEntry {
457                binding: 0,
458                resource: viewport_buffer.as_entire_binding(),
459            }],
460        });
461
462        // Atlas texture (R8Unorm, 512x512)
463        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
464            label: Some("glyph-atlas"),
465            size: wgpu::Extent3d {
466                width: ATLAS_SIZE,
467                height: ATLAS_SIZE,
468                depth_or_array_layers: 1,
469            },
470            mip_level_count: 1,
471            sample_count: 1,
472            dimension: wgpu::TextureDimension::D2,
473            format: wgpu::TextureFormat::R8Unorm,
474            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
475            view_formats: &[],
476        });
477
478        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
479        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
480            mag_filter: wgpu::FilterMode::Linear,
481            min_filter: wgpu::FilterMode::Linear,
482            ..Default::default()
483        });
484
485        let tex_bind_group_layout =
486            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
487                label: Some("tex-layout"),
488                entries: &[
489                    wgpu::BindGroupLayoutEntry {
490                        binding: 0,
491                        visibility: wgpu::ShaderStages::FRAGMENT,
492                        ty: wgpu::BindingType::Texture {
493                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
494                            view_dimension: wgpu::TextureViewDimension::D2,
495                            multisampled: false,
496                        },
497                        count: None,
498                    },
499                    wgpu::BindGroupLayoutEntry {
500                        binding: 1,
501                        visibility: wgpu::ShaderStages::FRAGMENT,
502                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
503                        count: None,
504                    },
505                ],
506            });
507
508        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
509            label: Some("atlas-bg"),
510            layout: &tex_bind_group_layout,
511            entries: &[
512                wgpu::BindGroupEntry {
513                    binding: 0,
514                    resource: wgpu::BindingResource::TextureView(&atlas_view),
515                },
516                wgpu::BindGroupEntry {
517                    binding: 1,
518                    resource: wgpu::BindingResource::Sampler(&sampler),
519                },
520            ],
521        });
522
523        // Pipeline
524        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
525            label: Some("truce-gpu-pipeline-layout"),
526            bind_group_layouts: &[
527                Some(&viewport_bind_group_layout),
528                Some(&tex_bind_group_layout),
529            ],
530            immediate_size: 0,
531        });
532
533        let vertex_layout = wgpu::VertexBufferLayout {
534            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
535            step_mode: wgpu::VertexStepMode::Vertex,
536            attributes: &[
537                // position
538                wgpu::VertexAttribute {
539                    offset: 0,
540                    shader_location: 0,
541                    format: wgpu::VertexFormat::Float32x2,
542                },
543                // color
544                wgpu::VertexAttribute {
545                    offset: 8,
546                    shader_location: 1,
547                    format: wgpu::VertexFormat::Float32x4,
548                },
549                // uv
550                wgpu::VertexAttribute {
551                    offset: 24,
552                    shader_location: 2,
553                    format: wgpu::VertexFormat::Float32x2,
554                },
555                // tex_mode
556                wgpu::VertexAttribute {
557                    offset: 32,
558                    shader_location: 3,
559                    format: wgpu::VertexFormat::Float32,
560                },
561            ],
562        };
563
564        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
565            label: Some("truce-gpu-pipeline"),
566            layout: Some(&pipeline_layout),
567            vertex: wgpu::VertexState {
568                module: &shader,
569                entry_point: Some("vs_main"),
570                buffers: &[vertex_layout],
571                compilation_options: wgpu::PipelineCompilationOptions::default(),
572            },
573            fragment: Some(wgpu::FragmentState {
574                module: &shader,
575                entry_point: Some("fs_main"),
576                targets: &[Some(wgpu::ColorTargetState {
577                    format: surface_format,
578                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
579                    write_mask: wgpu::ColorWrites::ALL,
580                })],
581                compilation_options: wgpu::PipelineCompilationOptions::default(),
582            }),
583            primitive: wgpu::PrimitiveState {
584                topology: wgpu::PrimitiveTopology::TriangleList,
585                strip_index_format: None,
586                front_face: wgpu::FrontFace::Ccw,
587                cull_mode: None,
588                unclipped_depth: false,
589                polygon_mode: wgpu::PolygonMode::Fill,
590                conservative: false,
591            },
592            depth_stencil: None,
593            multisample: wgpu::MultisampleState {
594                count: 4,
595                mask: !0,
596                alpha_to_coverage_enabled: false,
597            },
598            multiview_mask: None,
599            cache: None,
600        });
601
602        // Font
603        let font =
604            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
605                .expect("failed to parse embedded font");
606
607        Some(Self {
608            device,
609            queue,
610            surface: Some(surface),
611            surface_config: Some(surface_config),
612            pipeline,
613            target_format: surface_format,
614            msaa_texture,
615            msaa_width: width,
616            msaa_height: height,
617            vertices: Vec::with_capacity(4096),
618            indices: Vec::with_capacity(8192),
619            batches: Vec::new(),
620            glyph_atlas: GlyphAtlas::new(),
621            font,
622            atlas_texture,
623            atlas_bind_group,
624            tex_bind_group_layout,
625            sampler,
626            images: Vec::new(),
627            viewport_buffer,
628            viewport_bind_group,
629            clear_color: None,
630            present_clear_default: wgpu::Color::BLACK,
631            width,
632            height,
633            scale,
634        })
635    }
636
637    /// Create a GPU backend from a raw `CAMetalLayer` pointer (macOS).
638    ///
639    /// `logical_w` / `logical_h` are in logical points; `scale` is the
640    /// layer's `contentsScale` (2.0 on Retina). The surface is
641    /// configured at `logical × scale` physical pixels, matching the
642    /// contract of [`Self::from_surface`] / [`Self::from_window`].
643    ///
644    /// # Safety
645    /// `metal_layer` must be a valid `CAMetalLayer*` that outlives the backend.
646    #[cfg(target_os = "macos")]
647    pub unsafe fn from_metal_layer(
648        metal_layer: *mut c_void,
649        logical_w: u32,
650        logical_h: u32,
651        scale: f32,
652    ) -> Option<Self> {
653        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
654        desc.backends = wgpu::Backends::METAL;
655        let instance = wgpu::Instance::new(desc);
656
657        let surface = unsafe {
658            instance
659                .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(metal_layer))
660        }
661        .ok()?;
662
663        Self::from_surface(&instance, surface, logical_w, logical_h, scale)
664    }
665
666    /// Create a GPU backend from a baseview window handle. baseview
667    /// is the macOS / Windows / Linux windowing layer - iOS does not
668    /// compile this constructor (the iOS editor builds its surface
669    /// directly from a `CAMetalLayer` attached to a `UIView`).
670    ///
671    /// # Safety
672    /// The window must remain valid for the lifetime of the backend.
673    #[cfg(not(target_os = "ios"))]
674    #[must_use]
675    pub unsafe fn from_window(
676        window: &baseview::Window,
677        logical_w: u32,
678        logical_h: u32,
679        scale: f32,
680    ) -> Option<Self> {
681        unsafe {
682            let instance = wgpu::Instance::new(crate::platform::editor_instance_descriptor());
683
684            let surface = crate::platform::create_wgpu_surface(&instance, window)?;
685            Self::from_surface(&instance, surface, logical_w, logical_h, scale)
686        }
687    }
688
689    /// Build a standalone `WgpuBackend` that records into encoders
690    /// supplied per-frame by the caller.
691    ///
692    /// Unlike [`Self::from_surface`] / `from_metal_layer` / [`Self::from_window`],
693    /// this constructor does **not** own a `wgpu::Surface` or manage
694    /// frame acquisition. The caller is expected to have its own render
695    /// loop, allocate command encoders, and present - this backend only
696    /// supplies the 2D widget pipeline, glyph atlas, and lyon-tessellated
697    /// primitive recording.
698    ///
699    /// Usage:
700    ///
701    /// ```ignore
702    /// let mut backend = WgpuBackend::new(
703    ///     device.clone(), queue.clone(),
704    ///     target_format, max_w, max_h,
705    /// ).expect("backend init");
706    ///
707    /// // per-frame, after the caller has drawn its own content into `view`:
708    /// backend.begin_frame(w, h);
709    /// truce_gui::widgets::draw(&mut backend, &layout, &theme, &snap, &mut state);
710    /// backend.finish(&mut encoder, &view);
711    /// // caller submits encoder + presents.
712    /// ```
713    ///
714    /// `max_logical_w` / `max_logical_h` are in logical points; `scale`
715    /// is the display scale factor (2.0 on Retina, 1.0 otherwise). The
716    /// MSAA texture is seeded at `logical × scale` physical pixels; if
717    /// a subsequent `begin_frame(logical_w, logical_h)` exceeds the
718    /// seed, the MSAA texture is reallocated transparently.
719    ///
720    /// Matches the coordinate contract of [`Self::from_surface`] /
721    /// [`Self::from_window`]: draw calls and event coordinates are logical
722    /// points; the backend multiplies by `scale` internally when
723    /// rasterizing.
724    ///
725    /// # Panics
726    ///
727    /// Panics if the embedded font fails to parse (bundled-asset
728    /// bug, never user input).
729    // Surface dimensions in pixels stay below 2^23, well within f32.
730    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
731    #[must_use]
732    pub fn new(
733        device: Arc<wgpu::Device>,
734        queue: Arc<wgpu::Queue>,
735        target_format: wgpu::TextureFormat,
736        max_logical_w: u32,
737        max_logical_h: u32,
738        scale: f32,
739    ) -> Option<Self> {
740        let scale = scale.max(0.0);
741        let width = truce_gui_types::to_physical_px(max_logical_w, f64::from(scale));
742        let height = truce_gui_types::to_physical_px(max_logical_h, f64::from(scale));
743
744        // Shader
745        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
746            label: Some("truce-gpu-shader"),
747            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
748        });
749
750        // Viewport uniform
751        let matrix = ortho_matrix(width as f32, height as f32);
752        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
753            label: Some("viewport"),
754            contents: bytemuck::cast_slice(&matrix),
755            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
756        });
757
758        let viewport_bind_group_layout =
759            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
760                label: Some("viewport-layout"),
761                entries: &[wgpu::BindGroupLayoutEntry {
762                    binding: 0,
763                    visibility: wgpu::ShaderStages::VERTEX,
764                    ty: wgpu::BindingType::Buffer {
765                        ty: wgpu::BufferBindingType::Uniform,
766                        has_dynamic_offset: false,
767                        min_binding_size: None,
768                    },
769                    count: None,
770                }],
771            });
772
773        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
774            label: Some("viewport-bg"),
775            layout: &viewport_bind_group_layout,
776            entries: &[wgpu::BindGroupEntry {
777                binding: 0,
778                resource: viewport_buffer.as_entire_binding(),
779            }],
780        });
781
782        // Glyph atlas
783        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
784            label: Some("glyph-atlas"),
785            size: wgpu::Extent3d {
786                width: ATLAS_SIZE,
787                height: ATLAS_SIZE,
788                depth_or_array_layers: 1,
789            },
790            mip_level_count: 1,
791            sample_count: 1,
792            dimension: wgpu::TextureDimension::D2,
793            format: wgpu::TextureFormat::R8Unorm,
794            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
795            view_formats: &[],
796        });
797        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
798        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
799            mag_filter: wgpu::FilterMode::Linear,
800            min_filter: wgpu::FilterMode::Linear,
801            ..Default::default()
802        });
803        let tex_bind_group_layout =
804            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
805                label: Some("tex-layout"),
806                entries: &[
807                    wgpu::BindGroupLayoutEntry {
808                        binding: 0,
809                        visibility: wgpu::ShaderStages::FRAGMENT,
810                        ty: wgpu::BindingType::Texture {
811                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
812                            view_dimension: wgpu::TextureViewDimension::D2,
813                            multisampled: false,
814                        },
815                        count: None,
816                    },
817                    wgpu::BindGroupLayoutEntry {
818                        binding: 1,
819                        visibility: wgpu::ShaderStages::FRAGMENT,
820                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
821                        count: None,
822                    },
823                ],
824            });
825        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
826            label: Some("atlas-bg"),
827            layout: &tex_bind_group_layout,
828            entries: &[
829                wgpu::BindGroupEntry {
830                    binding: 0,
831                    resource: wgpu::BindingResource::TextureView(&atlas_view),
832                },
833                wgpu::BindGroupEntry {
834                    binding: 1,
835                    resource: wgpu::BindingResource::Sampler(&sampler),
836                },
837            ],
838        });
839
840        // Pipeline
841        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
842            label: Some("truce-gpu-pipeline-layout"),
843            bind_group_layouts: &[
844                Some(&viewport_bind_group_layout),
845                Some(&tex_bind_group_layout),
846            ],
847            immediate_size: 0,
848        });
849
850        let vertex_layout = wgpu::VertexBufferLayout {
851            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
852            step_mode: wgpu::VertexStepMode::Vertex,
853            attributes: &[
854                wgpu::VertexAttribute {
855                    offset: 0,
856                    shader_location: 0,
857                    format: wgpu::VertexFormat::Float32x2,
858                },
859                wgpu::VertexAttribute {
860                    offset: 8,
861                    shader_location: 1,
862                    format: wgpu::VertexFormat::Float32x4,
863                },
864                wgpu::VertexAttribute {
865                    offset: 24,
866                    shader_location: 2,
867                    format: wgpu::VertexFormat::Float32x2,
868                },
869                wgpu::VertexAttribute {
870                    offset: 32,
871                    shader_location: 3,
872                    format: wgpu::VertexFormat::Float32,
873                },
874            ],
875        };
876
877        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
878            label: Some("truce-gpu-pipeline"),
879            layout: Some(&pipeline_layout),
880            vertex: wgpu::VertexState {
881                module: &shader,
882                entry_point: Some("vs_main"),
883                buffers: &[vertex_layout],
884                compilation_options: wgpu::PipelineCompilationOptions::default(),
885            },
886            fragment: Some(wgpu::FragmentState {
887                module: &shader,
888                entry_point: Some("fs_main"),
889                targets: &[Some(wgpu::ColorTargetState {
890                    format: target_format,
891                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
892                    write_mask: wgpu::ColorWrites::ALL,
893                })],
894                compilation_options: wgpu::PipelineCompilationOptions::default(),
895            }),
896            primitive: wgpu::PrimitiveState {
897                topology: wgpu::PrimitiveTopology::TriangleList,
898                ..Default::default()
899            },
900            depth_stencil: None,
901            multisample: wgpu::MultisampleState {
902                count: 4,
903                mask: !0,
904                alpha_to_coverage_enabled: false,
905            },
906            multiview_mask: None,
907            cache: None,
908        });
909
910        // MSAA
911        let msaa_texture = Self::create_msaa_view(&device, target_format, width, height);
912
913        let font =
914            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
915                .expect("failed to parse embedded font");
916
917        Some(Self {
918            device,
919            queue,
920            surface: None,
921            surface_config: None,
922            pipeline,
923            target_format,
924            msaa_texture,
925            msaa_width: width,
926            msaa_height: height,
927            vertices: Vec::with_capacity(4096),
928            indices: Vec::with_capacity(8192),
929            batches: Vec::new(),
930            glyph_atlas: GlyphAtlas::new(),
931            font,
932            atlas_texture,
933            atlas_bind_group,
934            tex_bind_group_layout,
935            sampler,
936            images: Vec::new(),
937            viewport_buffer,
938            viewport_bind_group,
939            clear_color: None,
940            present_clear_default: wgpu::Color::TRANSPARENT,
941            width,
942            height,
943            scale,
944        })
945    }
946
947    /// Prepare for recording a frame of `logical_w × logical_h` logical
948    /// points. The MSAA target and ortho matrix are sized at
949    /// `logical × self.scale()` physical pixels; widget draw calls use
950    /// logical coordinates.
951    ///
952    /// Resets accumulated geometry and the clear flag. Rebuilds the MSAA
953    /// texture if the physical size differs from the previous frame.
954    ///
955    /// Only meaningful when the backend was built via [`Self::new`]; the
956    /// surface-owning constructors drive their own frame lifecycle.
957    #[allow(clippy::cast_precision_loss)]
958    pub fn begin_frame(&mut self, logical_w: u32, logical_h: u32) {
959        let phys_w = truce_gui_types::to_physical_px(logical_w, f64::from(self.scale));
960        let phys_h = truce_gui_types::to_physical_px(logical_h, f64::from(self.scale));
961        self.vertices.clear();
962        self.indices.clear();
963        self.batches.clear();
964        self.clear_color = None;
965
966        if phys_w != self.width || phys_h != self.height {
967            self.width = phys_w;
968            self.height = phys_h;
969            let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
970            self.queue
971                .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
972        }
973
974        if phys_w != self.msaa_width || phys_h != self.msaa_height {
975            self.msaa_texture =
976                Self::create_msaa_view(&self.device, self.target_format, phys_w, phys_h);
977            self.msaa_width = phys_w;
978            self.msaa_height = phys_h;
979        }
980    }
981
982    /// Display scale factor: `logical × scale = physical`. Callers
983    /// sizing sibling GPU resources (e.g. an intermediate texture that
984    /// the backend will resolve into) should use this to stay
985    /// consistent with the backend's raster dimensions.
986    pub fn scale(&self) -> f32 {
987        self.scale
988    }
989
990    /// Update the display scale factor. The next [`Self::resize`] (or
991    /// [`Self::begin_frame`] in headless mode) recomputes physical
992    /// dimensions and reconfigures the surface / MSAA target. Callers
993    /// driving a windowed surface should follow with a `resize` so the
994    /// `surface_config` picks up the new size on the same frame; the
995    /// short-circuit in `resize` doesn't trigger because the scale
996    /// change makes the new physical dims differ from the old.
997    pub fn set_scale(&mut self, scale: f32) {
998        if scale.is_finite() && scale > 0.0 {
999            self.scale = scale;
1000        }
1001    }
1002
1003    /// Flush accumulated geometry into a single render pass on `view`,
1004    /// recorded into `encoder`. The caller retains ownership of both -
1005    /// this method neither submits the encoder nor calls `present()`.
1006    ///
1007    /// If `clear()` was called since the last `begin_frame`, the pass
1008    /// uses `LoadOp::Clear(clear_color)`; otherwise `LoadOp::Load` so
1009    /// any prior content in `view` is preserved (the common case when
1010    /// widgets overlay a custom render).
1011    pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
1012        self.flush_atlas();
1013
1014        if self.indices.is_empty() {
1015            self.clear_color = None;
1016            return;
1017        }
1018
1019        let vertex_buffer = self
1020            .device
1021            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1022                label: Some("vertices"),
1023                contents: bytemuck::cast_slice(&self.vertices),
1024                usage: wgpu::BufferUsages::VERTEX,
1025            });
1026
1027        let index_buffer = self
1028            .device
1029            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1030                label: Some("indices"),
1031                contents: bytemuck::cast_slice(&self.indices),
1032                usage: wgpu::BufferUsages::INDEX,
1033            });
1034
1035        // MSAA load/store must agree across frames: if we plan to `Load`
1036        // next pass, this pass must `Store` (otherwise the next pass loads
1037        // undefined contents). With a `resolve_target` set, `Discard` is
1038        // standard for MSAA - but it's only well-defined when we also
1039        // `Clear` on entry, since a fresh load after a discard is UB.
1040        let (load, store) = match self.clear_color {
1041            Some(c) => (wgpu::LoadOp::Clear(c), wgpu::StoreOp::Discard),
1042            None => (wgpu::LoadOp::Load, wgpu::StoreOp::Store),
1043        };
1044
1045        {
1046            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1047                label: Some("truce-gpu-frame"),
1048                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1049                    view: &self.msaa_texture,
1050                    resolve_target: Some(view),
1051                    ops: wgpu::Operations { load, store },
1052                    depth_slice: None,
1053                })],
1054                depth_stencil_attachment: None,
1055                timestamp_writes: None,
1056                occlusion_query_set: None,
1057                multiview_mask: None,
1058            });
1059
1060            pass.set_pipeline(&self.pipeline);
1061            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1062            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1063            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1064
1065            let total_indices = len_u32(self.indices.len());
1066            if self.batches.is_empty() {
1067                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1068                pass.draw_indexed(0..total_indices, 0, 0..1);
1069            } else {
1070                for i in 0..self.batches.len() {
1071                    let b = self.batches[i];
1072                    let end = self
1073                        .batches
1074                        .get(i + 1)
1075                        .map_or(total_indices, |n| n.index_start);
1076                    if end <= b.index_start {
1077                        continue;
1078                    }
1079                    let bg = match b.image {
1080                        None => &self.atlas_bind_group,
1081                        Some(img_id) => {
1082                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1083                                Some(entry) => &entry.bind_group,
1084                                None => continue,
1085                            }
1086                        }
1087                    };
1088                    pass.set_bind_group(1, bg, &[]);
1089                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1090                }
1091            }
1092        }
1093
1094        self.clear_color = None;
1095    }
1096
1097    fn create_msaa_view(
1098        device: &wgpu::Device,
1099        format: wgpu::TextureFormat,
1100        width: u32,
1101        height: u32,
1102    ) -> wgpu::TextureView {
1103        let tex = device.create_texture(&wgpu::TextureDescriptor {
1104            label: Some("msaa"),
1105            size: wgpu::Extent3d {
1106                width,
1107                height,
1108                depth_or_array_layers: 1,
1109            },
1110            mip_level_count: 1,
1111            sample_count: 4,
1112            dimension: wgpu::TextureDimension::D2,
1113            format,
1114            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1115            view_formats: &[],
1116        });
1117        tex.create_view(&wgpu::TextureViewDescriptor::default())
1118    }
1119
1120    fn create_msaa_texture(
1121        device: &wgpu::Device,
1122        config: &wgpu::SurfaceConfiguration,
1123    ) -> wgpu::TextureView {
1124        let tex = device.create_texture(&wgpu::TextureDescriptor {
1125            label: Some("msaa"),
1126            size: wgpu::Extent3d {
1127                width: config.width,
1128                height: config.height,
1129                depth_or_array_layers: 1,
1130            },
1131            mip_level_count: 1,
1132            sample_count: 4,
1133            dimension: wgpu::TextureDimension::D2,
1134            format: config.format,
1135            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1136            view_formats: &[],
1137        });
1138        tex.create_view(&wgpu::TextureViewDescriptor::default())
1139    }
1140
1141    /// Resize the wgpu surface, MSAA texture, and viewport projection.
1142    ///
1143    /// `logical_w` and `logical_h` are in logical points (same coordinate
1144    /// space as `BuiltinEditor::size()`). Returns `true` if the surface
1145    /// was actually reconfigured.
1146    #[allow(clippy::cast_precision_loss)]
1147    pub fn resize(&mut self, logical_w: u32, logical_h: u32) -> bool {
1148        // Belt-and-braces: cap each axis at whatever the adapter
1149        // granted us in `from_surface`. The required_limits passed in
1150        // are adapter-native, but a host that ignores `gui_set_size`
1151        // could still hand us a logical*scale request past the cap.
1152        let max_dim = self.device.limits().max_texture_dimension_2d.max(1);
1153        let new_w =
1154            truce_gui_types::to_physical_px(logical_w, f64::from(self.scale)).clamp(1, max_dim);
1155        let new_h =
1156            truce_gui_types::to_physical_px(logical_h, f64::from(self.scale)).clamp(1, max_dim);
1157        if new_w == self.width && new_h == self.height {
1158            return false;
1159        }
1160        self.width = new_w;
1161        self.height = new_h;
1162
1163        if let Some(ref surface) = self.surface
1164            && let Some(ref mut config) = self.surface_config
1165        {
1166            config.width = new_w;
1167            config.height = new_h;
1168            surface.configure(&self.device, config);
1169            self.msaa_texture = Self::create_msaa_texture(&self.device, config);
1170        }
1171
1172        // Update the orthographic projection matrix.
1173        let matrix = ortho_matrix(new_w as f32, new_h as f32);
1174        self.queue
1175            .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
1176
1177        true
1178    }
1179
1180    // --- Geometry helpers ---
1181
1182    fn color_arr(c: Color) -> [f32; 4] {
1183        [c.r, c.g, c.b, c.a]
1184    }
1185
1186    /// Ensure the current (last) batch targets `image`. If not, close the
1187    /// current batch and open a new one. Call before pushing indices.
1188    fn ensure_batch(&mut self, image: Option<ImageId>) {
1189        let needs_new = self.batches.last().is_none_or(|last| last.image != image);
1190        if needs_new {
1191            self.batches.push(DrawBatch {
1192                index_start: len_u32(self.indices.len()),
1193                image,
1194            });
1195        }
1196    }
1197
1198    fn push_quad(&mut self, v0: Vertex, v1: Vertex, v2: Vertex, v3: Vertex) {
1199        self.ensure_batch(None);
1200        let base = len_u32(self.vertices.len());
1201        self.vertices.extend_from_slice(&[v0, v1, v2, v3]);
1202        self.indices
1203            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1204    }
1205
1206    /// Tessellate a lyon path as a filled shape and append to vertex/index buffers.
1207    fn fill_path(&mut self, path: &Path, color: [f32; 4]) {
1208        self.ensure_batch(None);
1209        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1210        let mut tessellator = FillTessellator::new();
1211        let _ = tessellator.tessellate_path(
1212            path,
1213            &FillOptions::tolerance(0.5),
1214            &mut BuffersBuilder::new(&mut buffers, |vertex: FillVertex| {
1215                let p = vertex.position();
1216                Vertex::solid(p.x, p.y, color)
1217            }),
1218        );
1219        let base = len_u32(self.vertices.len());
1220        self.vertices.extend_from_slice(&buffers.vertices);
1221        self.indices
1222            .extend(buffers.indices.iter().map(|i| i + base));
1223    }
1224
1225    /// Tessellate a lyon path as a stroked shape and append to vertex/index buffers.
1226    fn stroke_path(&mut self, path: &Path, color: [f32; 4], opts: &StrokeOptions) {
1227        self.ensure_batch(None);
1228        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1229        let mut tessellator = StrokeTessellator::new();
1230        let _ = tessellator.tessellate_path(
1231            path,
1232            opts,
1233            &mut BuffersBuilder::new(&mut buffers, |vertex: StrokeVertex| {
1234                let p = vertex.position();
1235                Vertex::solid(p.x, p.y, color)
1236            }),
1237        );
1238        let base = len_u32(self.vertices.len());
1239        self.vertices.extend_from_slice(&buffers.vertices);
1240        self.indices
1241            .extend(buffers.indices.iter().map(|i| i + base));
1242    }
1243
1244    /// Upload pending glyph atlas writes to the GPU.
1245    fn flush_atlas(&mut self) {
1246        for (x, y, w, h, data) in self.glyph_atlas.pending.drain(..) {
1247            if w == 0 || h == 0 {
1248                continue;
1249            }
1250            self.queue.write_texture(
1251                wgpu::TexelCopyTextureInfo {
1252                    texture: &self.atlas_texture,
1253                    mip_level: 0,
1254                    origin: wgpu::Origin3d { x, y, z: 0 },
1255                    aspect: wgpu::TextureAspect::All,
1256                },
1257                &data,
1258                wgpu::TexelCopyBufferLayout {
1259                    offset: 0,
1260                    bytes_per_row: Some(w),
1261                    rows_per_image: Some(h),
1262                },
1263                wgpu::Extent3d {
1264                    width: w,
1265                    height: h,
1266                    depth_or_array_layers: 1,
1267                },
1268            );
1269        }
1270    }
1271}
1272
1273// ---------------------------------------------------------------------------
1274// RenderBackend implementation
1275// ---------------------------------------------------------------------------
1276
1277/// All `RenderBackend` methods accept coordinates in **logical points**.
1278/// The backend multiplies by `self.scale` to get physical pixel positions.
1279/// Font glyphs are rasterized at physical resolution for sharp text.
1280// Rasterizer math uses standard short names (`x`, `y`, `w`, `h`,
1281// `r`, `g`, `b`, `s` = scale, etc.).
1282#[allow(clippy::many_single_char_names)]
1283impl RenderBackend for WgpuBackend {
1284    fn clear(&mut self, color: Color) {
1285        self.clear_color = Some(wgpu::Color {
1286            r: f64::from(color.r),
1287            g: f64::from(color.g),
1288            b: f64::from(color.b),
1289            a: f64::from(color.a),
1290        });
1291        self.vertices.clear();
1292        self.indices.clear();
1293        self.batches.clear();
1294        // If a previous frame hit atlas overflow, do the eviction at the
1295        // frame boundary now - past frames' vertex buffers are gone, so
1296        // dropping the UV cache is safe. Glyphs re-rasterize lazily as
1297        // draw_text walks them this frame.
1298        if self.glyph_atlas.overflow_pending {
1299            self.glyph_atlas.clear();
1300        }
1301    }
1302
1303    fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
1304        let s = self.scale;
1305        let c = Self::color_arr(color);
1306        self.push_quad(
1307            Vertex::solid(x * s, y * s, c),
1308            Vertex::solid((x + w) * s, y * s, c),
1309            Vertex::solid((x + w) * s, (y + h) * s, c),
1310            Vertex::solid(x * s, (y + h) * s, c),
1311        );
1312    }
1313
1314    fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
1315        let s = self.scale;
1316        let c = Self::color_arr(color);
1317        let mut builder = Path::builder();
1318        builder.add_circle(
1319            point(cx * s, cy * s),
1320            radius * s,
1321            lyon_tessellation::path::Winding::Positive,
1322        );
1323        let path = builder.build();
1324        self.fill_path(&path, c);
1325    }
1326
1327    fn stroke_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color, width: f32) {
1328        let s = self.scale;
1329        let c = Self::color_arr(color);
1330        let mut builder = Path::builder();
1331        builder.add_circle(
1332            point(cx * s, cy * s),
1333            radius * s,
1334            lyon_tessellation::path::Winding::Positive,
1335        );
1336        let path = builder.build();
1337        let opts = StrokeOptions::tolerance(0.5).with_line_width(width * s);
1338        self.stroke_path(&path, c, &opts);
1339    }
1340
1341    #[allow(clippy::cast_precision_loss)]
1342    fn stroke_arc(
1343        &mut self,
1344        cx: f32,
1345        cy: f32,
1346        radius: f32,
1347        start_angle: f32,
1348        end_angle: f32,
1349        color: Color,
1350        width: f32,
1351    ) {
1352        let s = self.scale;
1353        let c = Self::color_arr(color);
1354        let segments = 64u32;
1355        let sweep = end_angle - start_angle;
1356        let step = sweep / segments as f32;
1357
1358        let mut builder = Path::builder();
1359        builder.begin(point(
1360            cx * s + radius * s * start_angle.cos(),
1361            cy * s + radius * s * start_angle.sin(),
1362        ));
1363        for i in 1..=segments {
1364            let angle = start_angle + step * i as f32;
1365            builder.line_to(point(
1366                cx * s + radius * s * angle.cos(),
1367                cy * s + radius * s * angle.sin(),
1368            ));
1369        }
1370        builder.end(false);
1371        let path = builder.build();
1372
1373        let opts = StrokeOptions::tolerance(0.5)
1374            .with_line_width(width * s)
1375            .with_line_cap(lyon_tessellation::LineCap::Round);
1376        self.stroke_path(&path, c, &opts);
1377    }
1378
1379    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, width: f32) {
1380        let s = self.scale;
1381        let c = Self::color_arr(color);
1382        let mut builder = Path::builder();
1383        builder.begin(point(x1 * s, y1 * s));
1384        builder.line_to(point(x2 * s, y2 * s));
1385        builder.end(false);
1386        let path = builder.build();
1387
1388        let opts = StrokeOptions::tolerance(0.5)
1389            .with_line_width(width * s)
1390            .with_line_cap(lyon_tessellation::LineCap::Round);
1391        self.stroke_path(&path, c, &opts);
1392    }
1393
1394    // Glyph cache key uses `(phys_size * 10.0) as u32` quantization,
1395    // matching `ensure_glyph`. Window-bounded coordinates fit in u32.
1396    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1397    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
1398        let s = self.scale;
1399        let phys_size = size * s;
1400        let c = Self::color_arr(color);
1401        let line_metrics = self.font.horizontal_line_metrics(phys_size);
1402        let ascent = line_metrics.map_or(phys_size * 0.8, |m| m.ascent);
1403
1404        let mut cursor_x = x * s;
1405
1406        let chars: Vec<char> = text.chars().collect();
1407        for &ch in &chars {
1408            self.glyph_atlas.ensure_glyph(&self.font, ch, phys_size);
1409        }
1410
1411        // `.get` rather than `[..]` - when the atlas overflows mid-frame,
1412        // `ensure_glyph` skips the insert (see GlyphAtlas::ensure_glyph)
1413        // and we want to drop the missing glyph silently rather than
1414        // panic on lookup. The next frame clears the atlas and these
1415        // glyphs come back.
1416        for &ch in &chars {
1417            let key = (ch, (phys_size * 10.0) as u32);
1418            let Some(g) = self.glyph_atlas.glyphs.get(&key) else {
1419                continue;
1420            };
1421            let (u0, v0, u1, v1, gw, gh, y_off, advance) = (
1422                g.u0, g.v0, g.u1, g.v1, g.width, g.height, g.y_offset, g.advance,
1423            );
1424            // Snap the glyph quad to integer pixel positions on emit.
1425            // Atlas texels and screen pixels are 1:1 (glyphs are
1426            // rasterized at `phys_size`); the shared sampler is
1427            // `FilterMode::Linear`, so a fractional `gx`/`gy` would
1428            // produce per-output-pixel UV interpolation that mixes
1429            // neighbouring atlas texels and smears the glyph. Snapping
1430            // on emit (while `cursor_x` keeps full f32 advance) yields
1431            // crisp glyphs with the kerning error capped at ±0.5px,
1432            // invisible at UI text sizes.
1433            let gx = cursor_x.round();
1434            let gy = (y * s + ascent - y_off - gh).round();
1435
1436            self.push_quad(
1437                Vertex::glyph(gx, gy, c, u0, v0),
1438                Vertex::glyph(gx + gw, gy, c, u1, v0),
1439                Vertex::glyph(gx + gw, gy + gh, c, u1, v1),
1440                Vertex::glyph(gx, gy + gh, c, u0, v1),
1441            );
1442
1443            cursor_x += advance;
1444        }
1445    }
1446
1447    fn text_width(&self, text: &str, size: f32) -> f32 {
1448        let phys_size = size * self.scale;
1449        // Sum advance widths via the local fontdue instance. This used
1450        // to delegate to `truce_gui::font::text_width_fontdue` (a
1451        // glyph-cached version); doing the math inline keeps
1452        // truce-gpu independent of truce-gui.
1453        let phys: f32 = text
1454            .chars()
1455            .map(|ch| self.font.metrics(ch, phys_size).advance_width)
1456            .sum();
1457        phys / self.scale
1458    }
1459
1460    fn register_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
1461        let expected = (width as usize) * (height as usize) * 4;
1462        if width == 0 || height == 0 || rgba.len() < expected {
1463            return ImageId::INVALID;
1464        }
1465
1466        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1467            label: Some("image"),
1468            size: wgpu::Extent3d {
1469                width,
1470                height,
1471                depth_or_array_layers: 1,
1472            },
1473            mip_level_count: 1,
1474            sample_count: 1,
1475            dimension: wgpu::TextureDimension::D2,
1476            format: wgpu::TextureFormat::Rgba8Unorm,
1477            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1478            view_formats: &[],
1479        });
1480
1481        self.queue.write_texture(
1482            wgpu::TexelCopyTextureInfo {
1483                texture: &texture,
1484                mip_level: 0,
1485                origin: wgpu::Origin3d::ZERO,
1486                aspect: wgpu::TextureAspect::All,
1487            },
1488            &rgba[..expected],
1489            wgpu::TexelCopyBufferLayout {
1490                offset: 0,
1491                bytes_per_row: Some(width * 4),
1492                rows_per_image: Some(height),
1493            },
1494            wgpu::Extent3d {
1495                width,
1496                height,
1497                depth_or_array_layers: 1,
1498            },
1499        );
1500
1501        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1502        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1503            label: Some("image-bg"),
1504            layout: &self.tex_bind_group_layout,
1505            entries: &[
1506                wgpu::BindGroupEntry {
1507                    binding: 0,
1508                    resource: wgpu::BindingResource::TextureView(&view),
1509                },
1510                wgpu::BindGroupEntry {
1511                    binding: 1,
1512                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1513                },
1514            ],
1515        });
1516
1517        let entry = ImageEntry {
1518            _texture: texture,
1519            bind_group,
1520        };
1521
1522        if let Some((idx, slot)) = self
1523            .images
1524            .iter_mut()
1525            .enumerate()
1526            .find(|(_, s)| s.is_none())
1527        {
1528            *slot = Some(entry);
1529            return ImageId(len_u32(idx));
1530        }
1531        let id = len_u32(self.images.len());
1532        self.images.push(Some(entry));
1533        ImageId(id)
1534    }
1535
1536    fn unregister_image(&mut self, id: ImageId) {
1537        if let Some(slot) = self.images.get_mut(id.0 as usize) {
1538            *slot = None;
1539        }
1540    }
1541
1542    fn draw_image(&mut self, id: ImageId, x: f32, y: f32, w: f32, h: f32) {
1543        if self
1544            .images
1545            .get(id.0 as usize)
1546            .and_then(|s| s.as_ref())
1547            .is_none()
1548        {
1549            return;
1550        }
1551        self.ensure_batch(Some(id));
1552
1553        let s = self.scale;
1554        let c = [1.0, 1.0, 1.0, 1.0];
1555        let base = len_u32(self.vertices.len());
1556        self.vertices.extend_from_slice(&[
1557            Vertex::image(x * s, y * s, c, 0.0, 0.0),
1558            Vertex::image((x + w) * s, y * s, c, 1.0, 0.0),
1559            Vertex::image((x + w) * s, (y + h) * s, c, 1.0, 1.0),
1560            Vertex::image(x * s, (y + h) * s, c, 0.0, 1.0),
1561        ]);
1562        self.indices
1563            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1564    }
1565
1566    fn present(&mut self) {
1567        // Upload any pending glyph atlas writes (before borrowing surface)
1568        self.flush_atlas();
1569
1570        let Some(surface) = &self.surface else {
1571            return; // headless - no surface to present to
1572        };
1573
1574        // Acquire a swapchain frame, recovering from a stale surface.
1575        // After a window resize on X11/Vulkan the surface reports
1576        // `Outdated` and stays that way until it is reconfigured - even
1577        // reconfiguring to the same size clears the flag, so a plain
1578        // skip-the-frame would freeze the editor on its pre-resize image
1579        // with the desktop showing through the newly exposed area. On
1580        // `Outdated` / `Lost` / `Validation` we reconfigure and retry;
1581        // `Timeout` / `Occluded` are transient, so we skip this frame.
1582        let mut acquired = None;
1583        for _ in 0..2 {
1584            match surface.get_current_texture() {
1585                wgpu::CurrentSurfaceTexture::Success(frame)
1586                | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
1587                    acquired = Some(frame);
1588                    break;
1589                }
1590                wgpu::CurrentSurfaceTexture::Outdated
1591                | wgpu::CurrentSurfaceTexture::Lost
1592                | wgpu::CurrentSurfaceTexture::Validation => {
1593                    if let Some(config) = &self.surface_config {
1594                        surface.configure(&self.device, config);
1595                    }
1596                }
1597                wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
1598                    return;
1599                }
1600            }
1601        }
1602        let Some(frame) = acquired else {
1603            return;
1604        };
1605        let frame_view = frame
1606            .texture
1607            .create_view(&wgpu::TextureViewDescriptor::default());
1608
1609        if self.vertices.is_empty() {
1610            // No draws this frame, but the surface is double/triple-buffered
1611            // - without a render pass the swap chain shows whatever was
1612            // there before (often the second-prior frame, producing a
1613            // visible flicker on idle). Issue a clear-only pass so the
1614            // surface ends up at `clear_color`.
1615            self.clear_only_pass(&frame_view);
1616            frame.present();
1617            return;
1618        }
1619
1620        self.render_pass(&frame_view);
1621        frame.present();
1622    }
1623}
1624
1625impl WgpuBackend {
1626    /// Issue a render pass that only clears the surface. Used by
1627    /// `present()` when there is no geometry - without it the swap chain
1628    /// would show stale buffer contents. Always clears (`Load` would
1629    /// surface prior-frame garbage), falling back to
1630    /// `present_clear_default` when no `clear()` was requested.
1631    fn clear_only_pass(&mut self, resolve_target: &wgpu::TextureView) {
1632        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1633        let mut encoder = self
1634            .device
1635            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1636                label: Some("clear-only"),
1637            });
1638        {
1639            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1640                label: Some("clear-only"),
1641                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1642                    view: &self.msaa_texture,
1643                    resolve_target: Some(resolve_target),
1644                    ops: wgpu::Operations {
1645                        load: wgpu::LoadOp::Clear(clear_color),
1646                        store: wgpu::StoreOp::Discard,
1647                    },
1648                    depth_slice: None,
1649                })],
1650                depth_stencil_attachment: None,
1651                timestamp_writes: None,
1652                occlusion_query_set: None,
1653                multiview_mask: None,
1654            });
1655        }
1656        self.queue.submit(std::iter::once(encoder.finish()));
1657    }
1658
1659    /// Render accumulated geometry to a texture view (shared by present + headless).
1660    fn render_pass(&mut self, resolve_target: &wgpu::TextureView) {
1661        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1662        let vertex_buffer = self
1663            .device
1664            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1665                label: Some("vertices"),
1666                contents: bytemuck::cast_slice(&self.vertices),
1667                usage: wgpu::BufferUsages::VERTEX,
1668            });
1669
1670        let index_buffer = self
1671            .device
1672            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1673                label: Some("indices"),
1674                contents: bytemuck::cast_slice(&self.indices),
1675                usage: wgpu::BufferUsages::INDEX,
1676            });
1677
1678        let mut encoder = self
1679            .device
1680            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1681                label: Some("frame"),
1682            });
1683
1684        {
1685            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1686                label: Some("main"),
1687                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1688                    view: &self.msaa_texture,
1689                    resolve_target: Some(resolve_target),
1690                    ops: wgpu::Operations {
1691                        load: wgpu::LoadOp::Clear(clear_color),
1692                        store: wgpu::StoreOp::Discard,
1693                    },
1694                    depth_slice: None,
1695                })],
1696                depth_stencil_attachment: None,
1697                timestamp_writes: None,
1698                occlusion_query_set: None,
1699                multiview_mask: None,
1700            });
1701
1702            pass.set_pipeline(&self.pipeline);
1703            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1704            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1705            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1706
1707            let total_indices = len_u32(self.indices.len());
1708            if self.batches.is_empty() {
1709                // Backwards-compatible path: no batching recorded (e.g. a
1710                // caller that bypassed clear()). Draw everything with the
1711                // atlas bind group.
1712                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1713                pass.draw_indexed(0..total_indices, 0, 0..1);
1714            } else {
1715                for i in 0..self.batches.len() {
1716                    let b = self.batches[i];
1717                    let end = self
1718                        .batches
1719                        .get(i + 1)
1720                        .map_or(total_indices, |n| n.index_start);
1721                    if end <= b.index_start {
1722                        continue;
1723                    }
1724                    let bg = match b.image {
1725                        None => &self.atlas_bind_group,
1726                        Some(img_id) => {
1727                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1728                                Some(entry) => &entry.bind_group,
1729                                // Image was unregistered mid-frame; skip draw.
1730                                None => continue,
1731                            }
1732                        }
1733                    };
1734                    pass.set_bind_group(1, bg, &[]);
1735                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1736                }
1737            }
1738        }
1739
1740        self.queue.submit(std::iter::once(encoder.finish()));
1741    }
1742
1743    /// Create a headless GPU backend (no window or surface).
1744    /// Used for snapshot testing.
1745    ///
1746    /// # Panics
1747    ///
1748    /// Panics if the embedded font fails to parse (bundled-asset
1749    /// bug, never user input).
1750    // Surface dimensions in pixels stay below 2^23, well within f32.
1751    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
1752    #[must_use]
1753    pub fn headless(width: u32, height: u32, scale: f32) -> Option<Self> {
1754        let phys_w = truce_gui_types::to_physical_px(width, f64::from(scale));
1755        let phys_h = truce_gui_types::to_physical_px(height, f64::from(scale));
1756
1757        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
1758        desc.backends = wgpu::Backends::PRIMARY;
1759        let instance = wgpu::Instance::new(desc);
1760
1761        // Headless: there is no `wgpu::Surface` to constrain the adapter
1762        // pick against, so `compatible_surface` is `None`. On a multi-GPU
1763        // host (e.g. discrete + integrated, or NVIDIA Optimus) wgpu may
1764        // pick a different physical adapter than the live render path's
1765        // `compatible_surface: Some(&surface)`, which can produce subtle
1766        // rasterization differences (driver-specific shader compile, MSAA
1767        // resolve, sRGB rounding). Bake screenshot baselines on the host
1768        // you gate from - this is the same constraint already documented
1769        // in `cargo truce screenshot --check`.
1770        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1771            power_preference: wgpu::PowerPreference::HighPerformance,
1772            compatible_surface: None,
1773            force_fallback_adapter: false,
1774        }))
1775        .ok()?;
1776
1777        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1778            label: Some("truce-gpu-headless"),
1779            required_features: wgpu::Features::empty(),
1780            required_limits: adapter.limits(),
1781            experimental_features: wgpu::ExperimentalFeatures::default(),
1782            memory_hints: wgpu::MemoryHints::Performance,
1783            trace: wgpu::Trace::Off,
1784        }))
1785        .ok()?;
1786        let device = Arc::new(device);
1787        let queue = Arc::new(queue);
1788
1789        // Use non-sRGB to match the windowed path (which picks !is_srgb())
1790        let texture_format = wgpu::TextureFormat::Rgba8Unorm;
1791
1792        // MSAA texture
1793        let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
1794            label: Some("msaa"),
1795            size: wgpu::Extent3d {
1796                width: phys_w,
1797                height: phys_h,
1798                depth_or_array_layers: 1,
1799            },
1800            mip_level_count: 1,
1801            sample_count: 4,
1802            dimension: wgpu::TextureDimension::D2,
1803            format: texture_format,
1804            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1805            view_formats: &[],
1806        });
1807        let msaa_view = msaa_texture.create_view(&wgpu::TextureViewDescriptor::default());
1808
1809        // Shader
1810        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1811            label: Some("truce-gpu-shader"),
1812            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
1813        });
1814
1815        // Viewport
1816        let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
1817        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1818            label: Some("viewport"),
1819            contents: bytemuck::cast_slice(&matrix),
1820            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1821        });
1822
1823        let viewport_bind_group_layout =
1824            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1825                label: Some("viewport-layout"),
1826                entries: &[wgpu::BindGroupLayoutEntry {
1827                    binding: 0,
1828                    visibility: wgpu::ShaderStages::VERTEX,
1829                    ty: wgpu::BindingType::Buffer {
1830                        ty: wgpu::BufferBindingType::Uniform,
1831                        has_dynamic_offset: false,
1832                        min_binding_size: None,
1833                    },
1834                    count: None,
1835                }],
1836            });
1837
1838        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1839            label: Some("viewport-bg"),
1840            layout: &viewport_bind_group_layout,
1841            entries: &[wgpu::BindGroupEntry {
1842                binding: 0,
1843                resource: viewport_buffer.as_entire_binding(),
1844            }],
1845        });
1846
1847        // Atlas
1848        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
1849            label: Some("glyph-atlas"),
1850            size: wgpu::Extent3d {
1851                width: ATLAS_SIZE,
1852                height: ATLAS_SIZE,
1853                depth_or_array_layers: 1,
1854            },
1855            mip_level_count: 1,
1856            sample_count: 1,
1857            dimension: wgpu::TextureDimension::D2,
1858            format: wgpu::TextureFormat::R8Unorm,
1859            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1860            view_formats: &[],
1861        });
1862        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
1863        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1864            mag_filter: wgpu::FilterMode::Linear,
1865            min_filter: wgpu::FilterMode::Linear,
1866            ..Default::default()
1867        });
1868        let tex_bind_group_layout =
1869            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1870                label: Some("tex-layout"),
1871                entries: &[
1872                    wgpu::BindGroupLayoutEntry {
1873                        binding: 0,
1874                        visibility: wgpu::ShaderStages::FRAGMENT,
1875                        ty: wgpu::BindingType::Texture {
1876                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1877                            view_dimension: wgpu::TextureViewDimension::D2,
1878                            multisampled: false,
1879                        },
1880                        count: None,
1881                    },
1882                    wgpu::BindGroupLayoutEntry {
1883                        binding: 1,
1884                        visibility: wgpu::ShaderStages::FRAGMENT,
1885                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1886                        count: None,
1887                    },
1888                ],
1889            });
1890        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1891            label: Some("atlas-bg"),
1892            layout: &tex_bind_group_layout,
1893            entries: &[
1894                wgpu::BindGroupEntry {
1895                    binding: 0,
1896                    resource: wgpu::BindingResource::TextureView(&atlas_view),
1897                },
1898                wgpu::BindGroupEntry {
1899                    binding: 1,
1900                    resource: wgpu::BindingResource::Sampler(&sampler),
1901                },
1902            ],
1903        });
1904
1905        // Pipeline
1906        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1907            label: Some("truce-gpu-pipeline-layout"),
1908            bind_group_layouts: &[
1909                Some(&viewport_bind_group_layout),
1910                Some(&tex_bind_group_layout),
1911            ],
1912            immediate_size: 0,
1913        });
1914
1915        let vertex_layout = wgpu::VertexBufferLayout {
1916            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1917            step_mode: wgpu::VertexStepMode::Vertex,
1918            attributes: &[
1919                wgpu::VertexAttribute {
1920                    offset: 0,
1921                    shader_location: 0,
1922                    format: wgpu::VertexFormat::Float32x2,
1923                },
1924                wgpu::VertexAttribute {
1925                    offset: 8,
1926                    shader_location: 1,
1927                    format: wgpu::VertexFormat::Float32x4,
1928                },
1929                wgpu::VertexAttribute {
1930                    offset: 24,
1931                    shader_location: 2,
1932                    format: wgpu::VertexFormat::Float32x2,
1933                },
1934                wgpu::VertexAttribute {
1935                    offset: 32,
1936                    shader_location: 3,
1937                    format: wgpu::VertexFormat::Float32,
1938                },
1939            ],
1940        };
1941
1942        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1943            label: Some("truce-gpu-pipeline"),
1944            layout: Some(&pipeline_layout),
1945            vertex: wgpu::VertexState {
1946                module: &shader,
1947                entry_point: Some("vs_main"),
1948                buffers: &[vertex_layout],
1949                compilation_options: wgpu::PipelineCompilationOptions::default(),
1950            },
1951            fragment: Some(wgpu::FragmentState {
1952                module: &shader,
1953                entry_point: Some("fs_main"),
1954                targets: &[Some(wgpu::ColorTargetState {
1955                    format: texture_format,
1956                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1957                    write_mask: wgpu::ColorWrites::ALL,
1958                })],
1959                compilation_options: wgpu::PipelineCompilationOptions::default(),
1960            }),
1961            primitive: wgpu::PrimitiveState {
1962                topology: wgpu::PrimitiveTopology::TriangleList,
1963                ..Default::default()
1964            },
1965            depth_stencil: None,
1966            multisample: wgpu::MultisampleState {
1967                count: 4,
1968                mask: !0,
1969                alpha_to_coverage_enabled: false,
1970            },
1971            multiview_mask: None,
1972            cache: None,
1973        });
1974
1975        let font =
1976            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
1977                .expect("failed to parse embedded font");
1978
1979        Some(Self {
1980            device,
1981            queue,
1982            surface: None,
1983            surface_config: None,
1984            pipeline,
1985            target_format: texture_format,
1986            msaa_texture: msaa_view,
1987            msaa_width: phys_w,
1988            msaa_height: phys_h,
1989            vertices: Vec::with_capacity(4096),
1990            indices: Vec::with_capacity(8192),
1991            batches: Vec::new(),
1992            glyph_atlas: GlyphAtlas::new(),
1993            font,
1994            atlas_texture,
1995            atlas_bind_group,
1996            tex_bind_group_layout,
1997            sampler,
1998            images: Vec::new(),
1999            viewport_buffer,
2000            viewport_bind_group,
2001            clear_color: None,
2002            present_clear_default: wgpu::Color::BLACK,
2003            width: phys_w,
2004            height: phys_h,
2005            scale,
2006        })
2007    }
2008
2009    /// Render to an offscreen texture and read back RGBA pixels.
2010    /// Only works for headless backends (no surface).
2011    ///
2012    /// # Panics
2013    ///
2014    /// Panics if `wgpu::Buffer::map_async` reports failure when reading
2015    /// back the GPU readback buffer - that indicates an adapter / driver
2016    /// fault rather than a recoverable runtime condition, so the
2017    /// snapshot path bubbles it up rather than papering over it.
2018    pub fn read_pixels(&mut self) -> Vec<u8> {
2019        self.flush_atlas();
2020
2021        let w = self.width;
2022        let h = self.height;
2023        let format = wgpu::TextureFormat::Rgba8Unorm;
2024
2025        // Offscreen resolve target
2026        let target_texture = self.device.create_texture(&wgpu::TextureDescriptor {
2027            label: Some("offscreen"),
2028            size: wgpu::Extent3d {
2029                width: w,
2030                height: h,
2031                depth_or_array_layers: 1,
2032            },
2033            mip_level_count: 1,
2034            sample_count: 1,
2035            dimension: wgpu::TextureDimension::D2,
2036            format,
2037            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2038            view_formats: &[],
2039        });
2040        let target_view = target_texture.create_view(&wgpu::TextureViewDescriptor::default());
2041
2042        // Render
2043        if !self.vertices.is_empty() {
2044            self.render_pass(&target_view);
2045        }
2046
2047        // Readback
2048        let bytes_per_row = (w * 4 + 255) & !255; // 256-byte aligned
2049        let readback_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2050            label: Some("readback"),
2051            size: u64::from(bytes_per_row * h),
2052            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2053            mapped_at_creation: false,
2054        });
2055
2056        let mut encoder = self
2057            .device
2058            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2059                label: Some("readback"),
2060            });
2061        encoder.copy_texture_to_buffer(
2062            wgpu::TexelCopyTextureInfo {
2063                texture: &target_texture,
2064                mip_level: 0,
2065                origin: wgpu::Origin3d::ZERO,
2066                aspect: wgpu::TextureAspect::All,
2067            },
2068            wgpu::TexelCopyBufferInfo {
2069                buffer: &readback_buf,
2070                layout: wgpu::TexelCopyBufferLayout {
2071                    offset: 0,
2072                    bytes_per_row: Some(bytes_per_row),
2073                    rows_per_image: None,
2074                },
2075            },
2076            wgpu::Extent3d {
2077                width: w,
2078                height: h,
2079                depth_or_array_layers: 1,
2080            },
2081        );
2082        self.queue.submit(std::iter::once(encoder.finish()));
2083
2084        // Map and copy
2085        let buf_slice = readback_buf.slice(..);
2086        let (tx, rx) = std::sync::mpsc::channel();
2087        buf_slice.map_async(wgpu::MapMode::Read, move |result| {
2088            tx.send(result).unwrap();
2089        });
2090        let _ = self.device.poll(wgpu::PollType::Wait {
2091            submission_index: None,
2092            timeout: None,
2093        });
2094        rx.recv().unwrap().expect("buffer map failed");
2095
2096        let mapped = buf_slice.get_mapped_range();
2097        let mut pixels = Vec::with_capacity((w * h * 4) as usize);
2098        for row in 0..h {
2099            let start = (row * bytes_per_row) as usize;
2100            let end = start + (w * 4) as usize;
2101            pixels.extend_from_slice(&mapped[start..end]);
2102        }
2103        drop(mapped);
2104        readback_buf.unmap();
2105
2106        // Shader writes premultiplied alpha (BlendState::PREMULTIPLIED_ALPHA_BLENDING),
2107        // but downstream consumers - `truce-test`'s reference-PNG comparison
2108        // and `cargo truce screenshot` output - assume straight RGBA, the
2109        // same convention `truce-slint::screenshot::render_with_state` uses.
2110        // Un-premultiply here so the GPU readback matches the headless
2111        // contract instead of leaking the GPU's internal format.
2112        for px in pixels.chunks_exact_mut(4) {
2113            let a = px[3];
2114            if a == 0 || a == 255 {
2115                continue;
2116            }
2117            let a16 = u16::from(a);
2118            // Round to nearest: (c * 255 + a/2) / a.
2119            px[0] = ((u16::from(px[0]) * 255 + a16 / 2) / a16).min(255) as u8;
2120            px[1] = ((u16::from(px[1]) * 255 + a16 / 2) / a16).min(255) as u8;
2121            px[2] = ((u16::from(px[2]) * 255 + a16 / 2) / a16).min(255) as u8;
2122        }
2123
2124        pixels
2125    }
2126}
2127
2128// ---------------------------------------------------------------------------
2129// Tests
2130// ---------------------------------------------------------------------------
2131
2132#[cfg(test)]
2133mod tests {
2134    use super::*;
2135
2136    #[test]
2137    fn vertex_size() {
2138        // 2 (pos) + 4 (color) + 2 (uv) + 1 (tex_mix) + 1 (pad) = 10 floats = 40 bytes
2139        let size = std::mem::size_of::<Vertex>();
2140        assert!(size > 0, "Vertex should have non-zero size: {size}");
2141    }
2142
2143    // Both ortho-matrix tests check that the helper maps top-left
2144    // screen-space origin (0, 0) to wgpu's top-left clip-space corner
2145    // (-1, +1) and bottom-right screen (w, h) to clip (+1, -1). The Y
2146    // flip is the `-2.0 / h` term in `ortho_matrix` - without it,
2147    // increasing screen-y would move the vertex *up* in clip space.
2148    #[test]
2149    fn ortho_matrix_maps_origin() {
2150        let m = ortho_matrix(800.0, 600.0);
2151        let x = m[0][0] * 0.0 + m[3][0];
2152        let y = m[1][1] * 0.0 + m[3][1];
2153        assert!((x - (-1.0)).abs() < 1e-6);
2154        assert!((y - 1.0).abs() < 1e-6);
2155    }
2156
2157    #[test]
2158    fn ortho_matrix_maps_bottom_right() {
2159        let m = ortho_matrix(800.0, 600.0);
2160        let x = m[0][0] * 800.0 + m[3][0];
2161        let y = m[1][1] * 600.0 + m[3][1];
2162        assert!((x - 1.0).abs() < 1e-6);
2163        assert!((y - (-1.0)).abs() < 1e-6);
2164    }
2165
2166    #[test]
2167    fn glyph_atlas_shelf_packing() {
2168        let font =
2169            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
2170                .unwrap();
2171        let mut atlas = GlyphAtlas::new();
2172
2173        // Pack a few glyphs
2174        atlas.ensure_glyph(&font, 'A', 14.0);
2175        atlas.ensure_glyph(&font, 'B', 14.0);
2176        atlas.ensure_glyph(&font, 'C', 14.0);
2177
2178        assert_eq!(atlas.glyphs.len(), 3);
2179        assert!(!atlas.pending.is_empty());
2180
2181        // Same glyph at same size should not create a new entry
2182        atlas.ensure_glyph(&font, 'A', 14.0);
2183        assert_eq!(atlas.glyphs.len(), 3);
2184    }
2185
2186    #[test]
2187    fn lyon_fill_circle_produces_triangles() {
2188        let mut builder = Path::builder();
2189        builder.add_circle(
2190            point(50.0, 50.0),
2191            10.0,
2192            lyon_tessellation::path::Winding::Positive,
2193        );
2194        let path = builder.build();
2195        let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
2196        let mut tess = FillTessellator::new();
2197        tess.tessellate_path(
2198            &path,
2199            &FillOptions::tolerance(0.5),
2200            &mut BuffersBuilder::new(&mut buffers, |v: FillVertex| {
2201                let p = v.position();
2202                [p.x, p.y]
2203            }),
2204        )
2205        .unwrap();
2206        assert!(buffers.vertices.len() >= 3);
2207        assert!(buffers.indices.len() >= 3);
2208    }
2209
2210    /// End-to-end smoke test for the standalone `new` / `begin_frame` /
2211    /// `finish` path: build a backend against a caller-owned device,
2212    /// record some primitives + text, render into an offscreen texture,
2213    /// and verify we wrote non-background pixels.
2214    #[test]
2215    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
2216    fn standalone_pipeline_renders() {
2217        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
2218        desc.backends = wgpu::Backends::PRIMARY;
2219        let instance = wgpu::Instance::new(desc);
2220        let Ok(adapter) =
2221            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2222                power_preference: wgpu::PowerPreference::HighPerformance,
2223                compatible_surface: None,
2224                force_fallback_adapter: false,
2225            }))
2226        else {
2227            return; // no GPU in this environment
2228        };
2229        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
2230            label: Some("standalone-test"),
2231            required_features: wgpu::Features::empty(),
2232            required_limits: adapter.limits(),
2233            experimental_features: wgpu::ExperimentalFeatures::default(),
2234            memory_hints: wgpu::MemoryHints::Performance,
2235            trace: wgpu::Trace::Off,
2236        }))
2237        .expect("request_device");
2238        let device = Arc::new(device);
2239        let queue = Arc::new(queue);
2240
2241        let w = 64u32;
2242        let h = 48u32;
2243        let format = wgpu::TextureFormat::Rgba8Unorm;
2244        let mut backend =
2245            WgpuBackend::new(Arc::clone(&device), Arc::clone(&queue), format, w, h, 1.0)
2246                .expect("backend new");
2247
2248        // Pre-fill the offscreen target with red so we can tell apart
2249        // "finish drew something" from "finish cleared to background".
2250        let target = device.create_texture(&wgpu::TextureDescriptor {
2251            label: Some("standalone-target"),
2252            size: wgpu::Extent3d {
2253                width: w,
2254                height: h,
2255                depth_or_array_layers: 1,
2256            },
2257            mip_level_count: 1,
2258            sample_count: 1,
2259            dimension: wgpu::TextureDimension::D2,
2260            format,
2261            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2262            view_formats: &[],
2263        });
2264        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
2265
2266        backend.begin_frame(w, h);
2267        backend.clear(Color::rgb(0.0, 0.0, 0.0));
2268        backend.fill_rect(8.0, 8.0, 16.0, 16.0, Color::rgb(0.0, 1.0, 0.0));
2269        backend.draw_text("x", 20.0, 20.0, 14.0, Color::rgb(1.0, 1.0, 1.0));
2270
2271        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2272            label: Some("standalone-enc"),
2273        });
2274        backend.finish(&mut encoder, &view);
2275
2276        // Copy target to a readback buffer and inspect.
2277        let bytes_per_row = (w * 4 + 255) & !255;
2278        let readback = device.create_buffer(&wgpu::BufferDescriptor {
2279            label: Some("readback"),
2280            size: u64::from(bytes_per_row * h),
2281            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2282            mapped_at_creation: false,
2283        });
2284        encoder.copy_texture_to_buffer(
2285            wgpu::TexelCopyTextureInfo {
2286                texture: &target,
2287                mip_level: 0,
2288                origin: wgpu::Origin3d::ZERO,
2289                aspect: wgpu::TextureAspect::All,
2290            },
2291            wgpu::TexelCopyBufferInfo {
2292                buffer: &readback,
2293                layout: wgpu::TexelCopyBufferLayout {
2294                    offset: 0,
2295                    bytes_per_row: Some(bytes_per_row),
2296                    rows_per_image: None,
2297                },
2298            },
2299            wgpu::Extent3d {
2300                width: w,
2301                height: h,
2302                depth_or_array_layers: 1,
2303            },
2304        );
2305        queue.submit(std::iter::once(encoder.finish()));
2306
2307        let slice = readback.slice(..);
2308        let (tx, rx) = std::sync::mpsc::channel();
2309        slice.map_async(wgpu::MapMode::Read, move |r| {
2310            tx.send(r).unwrap();
2311        });
2312        let _ = device.poll(wgpu::PollType::Wait {
2313            submission_index: None,
2314            timeout: None,
2315        });
2316        rx.recv().unwrap().unwrap();
2317        let mapped = slice.get_mapped_range();
2318
2319        // Probe the green rect center (16, 16) - should be ~green.
2320        let row_off = 16usize * bytes_per_row as usize;
2321        let px_off = row_off + 16 * 4;
2322        let r = mapped[px_off];
2323        let g = mapped[px_off + 1];
2324        let b = mapped[px_off + 2];
2325        assert!(g > 200, "green rect not rendered: got rgb=({r},{g},{b})");
2326        assert!(
2327            r < 50 && b < 50,
2328            "green rect leaked other channels: rgb=({r},{g},{b})"
2329        );
2330    }
2331}