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