Skip to main content

teksilo_render/
renderer.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use wgpu;
5
6use teksilo_canvas::RenderFrame;
7use teksilo_canvas::geometry::Transform2D;
8
9use crate::blur::{BlurPipelines, BlurPool};
10use crate::image_manager::ImageManager;
11use crate::path_atlas::PathAtlas;
12use crate::stream_buffer::StreamBuffers;
13use crate::vertex::{AnimQuadVertex, QuadVertex, RectVertex, SdfVertex, ShadowVertex};
14
15/// How many animated-quad slots the uniform buffer holds. Must match
16/// the array size in `shaders/anim_procedural.wgsl`. Bumping this
17/// requires updating the WGSL constant too (WGSL array sizes are
18/// static). 128 × 64 B = 8 KiB — well within UBO caps.
19const MAX_ANIM_SLOTS: usize = 128;
20
21/// GPU renderer that draws a RenderFrame using seven shader pipelines.
22pub struct Renderer {
23    device: wgpu::Device,
24    queue: wgpu::Queue,
25    /// `max_texture_dimension_2d` of the device behind `device`.
26    ///
27    /// Cached because both atlases grow to a compiled-in ceiling that a
28    /// downlevel device need not be able to allocate, and the glyph atlas is
29    /// sized by the text backend rather than by this crate.
30    max_texture_dimension: u32,
31    rect_pipeline: wgpu::RenderPipeline,
32    sdf_pipeline: wgpu::RenderPipeline,
33    quad_pipeline: wgpu::RenderPipeline,
34    shadow_pipeline: wgpu::RenderPipeline,
35    /// Gradient-filled path pipeline (Tier 3) — draws `PathEntry`s whose
36    /// `paint_data` is a gradient variant. Solid-filled paths keep using
37    /// the lean `quad_pipeline` above; see `path_gradient_quad_verts` /
38    /// `PathGradientVertex`. Shares its group(0) bind-group layout
39    /// (texture + sampler) with `quad_pipeline`, so it binds the same
40    /// `path_atlas_texture` bind group the solid path-quad batch uses.
41    path_gradient_pipeline: wgpu::RenderPipeline,
42    /// Procedural animated-quad pipeline — IndeterminateSweep,
43    /// SpinnerArc and future Pulse / Shimmer kinds. Binds group 0 to a
44    /// uniform buffer holding an array of `AnimParams` (one per slot).
45    anim_proc_pipeline: wgpu::RenderPipeline,
46    /// Sprite-atlas animated-quad pipeline — frame-cycling for
47    /// `AnimatedQuadKind::SpriteCycle`. Shares the same uniform buffer
48    /// as the procedural pipeline at group 0; group 1 carries the
49    /// per-atlas texture bind group. Reuses the quad_pipeline's
50    /// bind-group layout for group 1, so the bind groups that
51    /// `ImageManager` builds for static images are also usable here
52    /// without a second registration.
53    anim_sprite_pipeline: wgpu::RenderPipeline,
54    /// Uniform buffer backing both animated-quad pipelines' per-slot
55    /// state. Rewritten wholesale at the top of each `render()` from
56    /// `frame.anim_params`. Fixed size (`MAX_ANIM_SLOTS * 64 B`); the
57    /// upload in `render()` truncates if it ever exceeds.
58    anim_uniform_buffer: wgpu::Buffer,
59    /// Bind group for the animated pipelines (group 0 on both).
60    anim_uniform_bind_group: wgpu::BindGroup,
61    atlas_texture: Option<AtlasTexture>,
62    path_atlas: PathAtlas,
63    path_atlas_texture: Option<AtlasTexture>,
64    image_manager: ImageManager,
65    /// Persistent per-pipeline streaming buffers. Resized on demand at
66    /// the top of each `render()` call, then reused via `write_buffer`
67    /// for every batch flush in that frame — replaces the historical
68    /// per-flush `create_buffer_init` antipattern.
69    streams: StreamBuffers,
70    /// Dual-Kawase blur pipelines (downsample + upsample) and per-pass
71    /// uniform buffer. Built once at construction; consumed by the
72    /// `BeginBlurredSubtree` / `EndBlurredSubtree` handler in `render`.
73    blur_pipelines: BlurPipelines,
74    /// Recycled intermediate-texture pool for blur scopes. Begin-of-
75    /// frame resets per-texture in-use flags; textures unused for
76    /// several frames evict.
77    blur_pool: BlurPool,
78    /// Cached bind group layout for the quad pipeline's group(0)
79    /// (texture + sampler). Used to build per-frame bind groups that
80    /// expose blur-pool intermediates as image sources for the
81    /// compositing blit at the end of each blur scope.
82    quad_bind_group_layout: wgpu::BindGroupLayout,
83    /// Sampler used by the blur composite blit. Linear filtering so
84    /// the over-allocated bucket texture's used sub-rect samples
85    /// cleanly when composited onto a non-aligned target rect.
86    blur_composite_sampler: wgpu::Sampler,
87}
88
89struct AtlasTexture {
90    texture: wgpu::Texture,
91    bind_group: wgpu::BindGroup,
92    width: u32,
93    height: u32,
94}
95
96/// Active render target — the bottom of the stack is always the
97/// surface; intermediates push above it for the duration of a blur
98/// scope. Each entry tracks both the target's identity and per-target
99/// state that survives across multiple segment passes against the
100/// same target (e.g. when an inner blur scope ends and we re-open
101/// the parent intermediate to draw additional commands).
102struct ActiveTarget {
103    /// `None` ⇒ surface (the caller-provided texture view).
104    /// `Some(handle)` ⇒ a blur intermediate from `BlurPool`.
105    intermediate: Option<crate::blur::AcquiredTexture>,
106    /// Viewport dimensions for NDC conversion in this scope.
107    viewport_w: u32,
108    viewport_h: u32,
109    /// `false` until the first segment pass against this target runs;
110    /// controls whether the next pass uses Clear or Load.
111    opened: bool,
112    /// Blurred sub-tree results that nested scopes have queued for
113    /// compositing into THIS target on its next segment open. Drained
114    /// at the top of each segment.
115    pending_composites: Vec<PendingComposite>,
116    /// Intermediate-only metadata, populated when `intermediate.is_some()`.
117    /// Carried here (rather than in a separate `BlurScope` stack)
118    /// because End needs to look these up after popping the target.
119    blur_bounds: Option<teksilo_canvas::Rect>,
120    blur_radius_logical: Option<f32>,
121    used_w: Option<u32>,
122    used_h: Option<u32>,
123    bucket_w: Option<u32>,
124    bucket_h: Option<u32>,
125}
126
127impl ActiveTarget {
128    fn surface(viewport_w: u32, viewport_h: u32) -> Self {
129        Self {
130            intermediate: None,
131            viewport_w,
132            viewport_h,
133            opened: false,
134            pending_composites: Vec::new(),
135            blur_bounds: None,
136            blur_radius_logical: None,
137            used_w: None,
138            used_h: None,
139            bucket_w: None,
140            bucket_h: None,
141        }
142    }
143}
144
145/// One blurred sub-tree result waiting to be composited into a parent
146/// target's next render pass. Lives on `ActiveTarget::pending_composites`
147/// for the parent target.
148struct PendingComposite {
149    blurred_texture: crate::blur::AcquiredTexture,
150    used_w: u32,
151    used_h: u32,
152    bucket_w: u32,
153    bucket_h: u32,
154    bounds: teksilo_canvas::Rect,
155}
156
157impl Renderer {
158    /// Create a new renderer from an existing wgpu device and queue.
159    pub fn new(
160        device: wgpu::Device,
161        queue: wgpu::Queue,
162        surface_format: wgpu::TextureFormat,
163    ) -> Self {
164        // Read once, here: the atlases grow to a compiled-in ceiling that the
165        // device may not be able to honour.
166        let device_max_texture_dimension = device.limits().max_texture_dimension_2d;
167        let rect_pipeline = create_rect_pipeline(&device, surface_format);
168        let sdf_pipeline = create_sdf_pipeline(&device, surface_format);
169        let quad_pipeline = create_quad_pipeline(&device, surface_format);
170        // Must come after quad_pipeline — reuses its group(0) bind-group
171        // layout (texture + sampler) so the path atlas's bind group
172        // binds unchanged for both the solid and gradient path batches.
173        let path_gradient_pipeline = create_path_gradient_pipeline(
174            &device,
175            surface_format,
176            &quad_pipeline.get_bind_group_layout(0),
177        );
178        let shadow_pipeline = create_shadow_pipeline(&device, surface_format);
179        let (anim_proc_pipeline, anim_uniform_buffer, anim_uniform_bind_group, anim_uniform_layout) =
180            create_anim_proc_pipeline(&device, surface_format);
181        // Reuse the quad pipeline's texture/sampler layout so bind
182        // groups registered by `ImageManager` for static images work
183        // equally well as the sprite animation's atlas binding.
184        let quad_texture_layout = quad_pipeline.get_bind_group_layout(0);
185        let anim_sprite_pipeline = create_anim_sprite_pipeline(
186            &device,
187            surface_format,
188            &anim_uniform_layout,
189            &quad_texture_layout,
190        );
191
192        let quad_bind_group_layout = quad_pipeline.get_bind_group_layout(0);
193        let blur_pool = BlurPool::new(&device, surface_format);
194        let blur_pipelines =
195            BlurPipelines::new(&device, &blur_pool.bind_group_layout, surface_format);
196        let blur_composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
197            label: Some("blur_composite_sampler"),
198            address_mode_u: wgpu::AddressMode::ClampToEdge,
199            address_mode_v: wgpu::AddressMode::ClampToEdge,
200            address_mode_w: wgpu::AddressMode::ClampToEdge,
201            mag_filter: wgpu::FilterMode::Linear,
202            min_filter: wgpu::FilterMode::Linear,
203            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
204            ..Default::default()
205        });
206
207        Self {
208            device,
209            queue,
210            max_texture_dimension: device_max_texture_dimension,
211            rect_pipeline,
212            sdf_pipeline,
213            quad_pipeline,
214            path_gradient_pipeline,
215            shadow_pipeline,
216            anim_proc_pipeline,
217            anim_sprite_pipeline,
218            anim_uniform_buffer,
219            anim_uniform_bind_group,
220            atlas_texture: None,
221            path_atlas: {
222                let mut atlas = PathAtlas::new(512, 512);
223                atlas.cap_max_size(device_max_texture_dimension);
224                atlas
225            },
226            path_atlas_texture: None,
227            image_manager: ImageManager::new(),
228            streams: StreamBuffers::new(),
229            blur_pipelines,
230            blur_pool,
231            quad_bind_group_layout,
232            blur_composite_sampler,
233        }
234    }
235
236    /// Upload atlas texture data from the text backend.
237    pub fn upload_atlas(&mut self, width: u32, height: u32, pixels: &[u8]) {
238        if width == 0 || height == 0 {
239            return;
240        }
241        // The glyph atlas is sized by the text backend, which has its own
242        // compiled-in ceiling and no view of this device. Asking wgpu for a
243        // texture past `max_texture_dimension_2d` is a validation error, i.e. a
244        // crash — on precisely the downlevel hardware least able to report one.
245        // Keeping the previous atlas loses newly-rasterized glyphs, which draws
246        // as missing text: bad, but legible, and recoverable the moment the
247        // backend evicts back under the cap.
248        if width > self.max_texture_dimension || height > self.max_texture_dimension {
249            eprintln!(
250                "teksilo-render: glyph atlas {width}x{height} exceeds this device's \
251                 max texture dimension ({}); skipping upload",
252                self.max_texture_dimension
253            );
254            return;
255        }
256
257        let needs_recreate = self
258            .atlas_texture
259            .as_ref()
260            .is_none_or(|t| t.width != width || t.height != height);
261
262        if needs_recreate {
263            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
264                label: Some("glyph_atlas"),
265                size: wgpu::Extent3d {
266                    width,
267                    height,
268                    depth_or_array_layers: 1,
269                },
270                mip_level_count: 1,
271                sample_count: 1,
272                dimension: wgpu::TextureDimension::D2,
273                format: wgpu::TextureFormat::Rgba8UnormSrgb,
274                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
275                view_formats: &[],
276            });
277
278            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
279            // Linear, not Nearest: glyph quads can be drawn under a scale
280            // transform (SceneView zoom, Scale wrapper), where nearest
281            // magnification turns texels into hard squares. Glyph origins
282            // are fractional (shaping advances, scroll), so linear is NOT
283            // automatically a no-op at identity — quads that map 1:1 onto
284            // their atlas bitmap are pixel-snapped at vertex emission
285            // (`QuadVertex::from_glyph_quad_transformed`), which makes
286            // linear sampling exact there; only residually scaled quads
287            // (mid-bucket zoom) actually filter. Safe for tinted text —
288            // the monochrome shader path ignores sampled RGB — and the
289            // 1px atlas gutter bounds bilinear bleed.
290            let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
291                mag_filter: wgpu::FilterMode::Linear,
292                min_filter: wgpu::FilterMode::Linear,
293                ..Default::default()
294            });
295
296            let bind_group_layout = self.quad_pipeline.get_bind_group_layout(0);
297            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
298                label: Some("atlas_bind_group"),
299                layout: &bind_group_layout,
300                entries: &[
301                    wgpu::BindGroupEntry {
302                        binding: 0,
303                        resource: wgpu::BindingResource::TextureView(&view),
304                    },
305                    wgpu::BindGroupEntry {
306                        binding: 1,
307                        resource: wgpu::BindingResource::Sampler(&sampler),
308                    },
309                ],
310            });
311
312            self.atlas_texture = Some(AtlasTexture {
313                texture,
314                bind_group,
315                width,
316                height,
317            });
318        }
319
320        if let Some(atlas) = &self.atlas_texture {
321            self.queue.write_texture(
322                wgpu::TexelCopyTextureInfo {
323                    texture: &atlas.texture,
324                    mip_level: 0,
325                    origin: wgpu::Origin3d::ZERO,
326                    aspect: wgpu::TextureAspect::All,
327                },
328                pixels,
329                wgpu::TexelCopyBufferLayout {
330                    offset: 0,
331                    bytes_per_row: Some(width * 4),
332                    rows_per_image: Some(height),
333                },
334                wgpu::Extent3d {
335                    width,
336                    height,
337                    depth_or_array_layers: 1,
338                },
339            );
340        }
341    }
342
343    /// Render a frame to the given surface texture view.
344    pub fn render(
345        &mut self,
346        frame: &RenderFrame,
347        view: &wgpu::TextureView,
348        scale_factor: f32,
349        viewport_width: u32,
350        viewport_height: u32,
351        clear_color: [f32; 4],
352    ) {
353        // Begin frame for path atlas LRU tracking
354        self.path_atlas.begin_frame();
355        // Reset blur intermediate-texture pool — marks every texture
356        // available, evicts ones unused for too long.
357        self.blur_pool.begin_frame();
358
359        // Process pending images: upload textures for newly embedded resources
360        for pending in &frame.pending_images {
361            if !self.image_manager.contains(&pending.name) {
362                let layout = self.quad_pipeline.get_bind_group_layout(0);
363                self.image_manager.register_image(
364                    &pending.name,
365                    pending.width,
366                    pending.height,
367                    &pending.pixels,
368                    &self.device,
369                    &self.queue,
370                    &layout,
371                );
372            }
373        }
374
375        // Pre-rasterize all paths in this frame into the path atlas. Cosmetic
376        // (device-space) strokes must rasterize the body at the view zoom
377        // active *where the path is drawn* so the border holds a constant
378        // device-pixel width (see PathAtlas::lookup_or_rasterize). Zoom is only
379        // known by replaying the transform commands, so we walk `draw_order`
380        // with the same SetTransform / PushTransform / PopTransform bookkeeping
381        // the main render loop uses and rasterize each path at its effective
382        // zoom. `path_placements` is indexed by path index (one Path command per
383        // entry). Logical strokes ignore the zoom; a path inside a blurred
384        // subtree may get a slightly off zoom estimate (acceptably rare —
385        // positioning is unaffected, only raster sharpness).
386        let mut path_placements: Vec<Option<crate::path_atlas::PathPlacement>> =
387            vec![None; frame.paths.len()];
388        {
389            let mut ptf_stack: Vec<Transform2D> = vec![Transform2D::IDENTITY];
390            let mut ptf_current = Transform2D::IDENTITY;
391            let device_t = |t: &Transform2D| Transform2D {
392                m: [
393                    t.m[0],
394                    t.m[1],
395                    t.m[2],
396                    t.m[3],
397                    t.m[4] * scale_factor,
398                    t.m[5] * scale_factor,
399                ],
400            };
401            for cmd in &frame.draw_order {
402                match cmd {
403                    teksilo_canvas::DrawCommand::SetTransform(t) => {
404                        let stack_top = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
405                        ptf_current = device_t(t).then(&stack_top);
406                    }
407                    teksilo_canvas::DrawCommand::PushTransform(t) => {
408                        let prev_top = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
409                        let new_top = device_t(t).then(&prev_top);
410                        ptf_stack.push(new_top);
411                        ptf_current = new_top;
412                    }
413                    teksilo_canvas::DrawCommand::PopTransform => {
414                        if ptf_stack.len() > 1 {
415                            ptf_stack.pop();
416                        }
417                        ptf_current = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
418                    }
419                    teksilo_canvas::DrawCommand::Path(idx) => {
420                        if let Some(entry) = frame.paths.get(*idx) {
421                            // Uniform scale of the linear part = view zoom
422                            // (no scale_factor — it lives only in the
423                            // translation column, see SetTransform handling).
424                            let zoom = ptf_current.m[0].hypot(ptf_current.m[1]);
425                            // Snap the quad to whole device pixels only when
426                            // nothing else is going to move it. Under the
427                            // identity transform (every dock, menu, button
428                            // and icon in a normal window — `PushTransform`
429                            // is not even emitted for an identity) the mask
430                            // can sample 1:1 and stay sharp; under a scale
431                            // or a translate animation it cannot, and
432                            // rounding would only make the path step between
433                            // pixels. See `PathAtlas::lookup_or_rasterize`.
434                            let snap = ptf_current == Transform2D::IDENTITY;
435                            path_placements[*idx] = self.path_atlas.lookup_or_rasterize(
436                                &entry.path,
437                                &entry.stroke_style,
438                                entry.fill_rule,
439                                entry.bounds,
440                                scale_factor,
441                                zoom,
442                                snap,
443                            );
444                        }
445                    }
446                    _ => {}
447                }
448            }
449        }
450
451        // Upload path atlas to GPU if dirty
452        if self.path_atlas.is_dirty() {
453            let (pw, ph) = self.path_atlas.size();
454            self.upload_path_atlas(pw, ph, self.path_atlas.pixels().to_vec());
455            self.path_atlas.mark_clean();
456        }
457
458        // Grow persistent streaming buffers to fit this frame's worst case.
459        let counts = stream_quad_counts(frame);
460        let StreamQuadCounts {
461            rect: rect_quads,
462            sdf: sdf_quads,
463            quad: quad_quads,
464            shadow: shadow_quads,
465            anim_proc: anim_proc_quads,
466            path_gradient: path_gradient_quads,
467        } = counts;
468        let max_quads = counts.max();
469
470        self.streams.rect.ensure_capacity(
471            &self.device,
472            (rect_quads * 4 * std::mem::size_of::<RectVertex>()) as u64,
473        );
474        self.streams.sdf.ensure_capacity(
475            &self.device,
476            (sdf_quads * 4 * std::mem::size_of::<SdfVertex>()) as u64,
477        );
478        self.streams.quad.ensure_capacity(
479            &self.device,
480            (quad_quads * 4 * std::mem::size_of::<QuadVertex>()) as u64,
481        );
482        self.streams.shadow.ensure_capacity(
483            &self.device,
484            (shadow_quads * 4 * std::mem::size_of::<ShadowVertex>()) as u64,
485        );
486        self.streams.anim_proc.ensure_capacity(
487            &self.device,
488            (anim_proc_quads * 4 * std::mem::size_of::<AnimQuadVertex>()) as u64,
489        );
490        self.streams.path_gradient.ensure_capacity(
491            &self.device,
492            (path_gradient_quads * 4 * std::mem::size_of::<crate::vertex::PathGradientVertex>())
493                as u64,
494        );
495        self.streams.index.ensure_capacity(
496            &self.device,
497            (max_quads * 6 * std::mem::size_of::<u32>()) as u64,
498        );
499        self.streams.reset();
500
501        // Upload animated-quad per-slot state for this frame. Truncate
502        // past MAX_ANIM_SLOTS — the registry allocates slots without a
503        // ceiling and growing the buffer would require recreating
504        // the bind group, so we just drop excess slots and warn in
505        // debug builds. In practice, 128 is well beyond typical UIs.
506        if !frame.anim_params.is_empty() {
507            let n = frame.anim_params.len().min(MAX_ANIM_SLOTS);
508            debug_assert!(
509                frame.anim_params.len() <= MAX_ANIM_SLOTS,
510                "AnimParams exceeds MAX_ANIM_SLOTS ({}); tail will be dropped",
511                MAX_ANIM_SLOTS
512            );
513            let bytes: &[u8] = bytemuck::cast_slice(&frame.anim_params[..n]);
514            self.queue.write_buffer(&self.anim_uniform_buffer, 0, bytes);
515        }
516
517        // Upload the full quad index pattern once — 6 u32s per quad, shared
518        // across every quad-based pipeline this frame. u32 indices avoid the
519        // u16 vertex-index ceiling (16 384 quads) for large batches.
520        let index_data: Vec<u32> = crate::vertex::generate_quad_indices(max_quads);
521        let index_binding = self
522            .streams
523            .index
524            .write(&self.queue, bytemuck::cast_slice(&index_data));
525
526        let mut encoder = self
527            .device
528            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
529                label: Some("teksilo_render"),
530            });
531
532        // Per-frame mutable viewport — overridden inside blur scopes
533        // (the offscreen intermediate is sized differently from the
534        // surface). Restored on `EndBlurredSubtree`.
535        let mut viewport_width = viewport_width;
536        let mut viewport_height = viewport_height;
537
538        {
539            let surface_clear_color = wgpu::Color {
540                r: clear_color[0] as f64,
541                g: clear_color[1] as f64,
542                b: clear_color[2] as f64,
543                a: clear_color[3] as f64,
544            };
545
546            // Target stack — bottom is the surface (never popped),
547            // intermediates pushed on `BeginBlurredSubtree` and popped
548            // on `EndBlurredSubtree`. The active target is always
549            // `target_stack.last_mut()`. Each target carries:
550            //   - opened: false until the first segment runs against
551            //     it (controls Clear vs Load on the next open)
552            //   - viewport dimensions for NDC conversion in this scope
553            //   - pending_composites: blurred quads that nested scopes
554            //     have queued for compositing into THIS target on its
555            //     next segment
556            let mut target_stack: Vec<ActiveTarget> =
557                vec![ActiveTarget::surface(viewport_width, viewport_height)];
558
559            // Clip rect stack for nested scroll areas.
560            // Each SetClip pushes a rect; the effective clip is the intersection.
561            // ClearClip pops the top and restores the previous intersection.
562            let mut clip_stack: Vec<[u32; 4]> = Vec::new(); // [x, y, w, h]
563
564            // Opacity stack for nested opacity groups
565            let mut opacity_stack: Vec<f32> = vec![1.0];
566            let mut current_opacity: f32 = 1.0;
567
568            // Blend mode stack
569            let mut blend_stack: Vec<teksilo_canvas::BlendMode> = Vec::new();
570            let mut current_blend = teksilo_canvas::BlendMode::Normal;
571            let _ = current_blend; // used to track state for future pipeline switching
572
573            // Transform stack — applied CPU-side to pixel positions before NDC conversion.
574            // The stack tracks subtree-level transforms pushed by the render walker
575            // (`PushTransform` / `PopTransform`); `current_transform` is always the
576            // top of the stack composed with whatever the most recent `SetTransform`
577            // command set within the current scope.
578            let mut transform_stack: Vec<Transform2D> = vec![Transform2D::IDENTITY];
579            let mut current_transform = Transform2D::IDENTITY;
580
581            // --- Batched rendering ---
582            // Accumulate vertices per pipeline, flush on state/pipeline changes.
583            // This produces one GPU buffer + one draw call per contiguous batch
584            // instead of two buffers per quad.
585            let mut rect_batch: Vec<RectVertex> = Vec::new();
586            let mut sdf_batch: Vec<SdfVertex> = Vec::new();
587            let mut quad_batch: Vec<QuadVertex> = Vec::new();
588            let mut shadow_batch: Vec<ShadowVertex> = Vec::new();
589            let mut anim_proc_batch: Vec<AnimQuadVertex> = Vec::new();
590            let mut path_gradient_batch: Vec<crate::vertex::PathGradientVertex> = Vec::new();
591
592            // Which pipeline the current quad batch uses (glyph atlas or path atlas;
593            // images draw individually with their own bind group).
594            // Flushed when the bind group source changes.
595            #[derive(Clone, Copy, PartialEq, Eq)]
596            enum QuadSource {
597                GlyphAtlas,
598                PathAtlas,
599            }
600            let mut quad_source: Option<QuadSource> = None;
601
602            // Flush helpers — each writes one batch into the persistent
603            // stream buffer and issues one draw call. The index buffer was
604            // written once at the top of `render()` and is shared.
605            //
606            // `$index_binding` is `Option<(&Buffer, u64 offset, u64 len)>`
607            // — `None` only if the frame had zero quads, in which case
608            // every batch is also empty and the flush is a no-op anyway.
609            macro_rules! flush_stream {
610                ($pass:expr, $queue:expr, $stream:expr, $pipeline:expr,
611                 $batch:expr, $index_binding:expr) => {
612                    if !$batch.is_empty() {
613                        let bytes: &[u8] = bytemuck::cast_slice(&$batch);
614                        if let (Some((vb, v_off, v_len)), Some((ib, _, _))) =
615                            ($stream.write($queue, bytes), $index_binding)
616                        {
617                            let quads = ($batch.len() / 4) as u32;
618                            let index_count = quads * 6;
619                            let index_bytes = (index_count as u64) * 4;
620                            $pass.set_pipeline($pipeline);
621                            $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
622                            $pass.set_index_buffer(
623                                ib.slice(0..index_bytes),
624                                wgpu::IndexFormat::Uint32,
625                            );
626                            $pass.draw_indexed(0..index_count, 0, 0..1);
627                        }
628                        $batch.clear();
629                    }
630                };
631            }
632
633            // Flush all pending batches (called on state changes).
634            macro_rules! flush_all {
635                ($pass:expr, $queue:expr, $streams:expr,
636                 $rp:expr, $sp:expr, $qp:expr, $pgp:expr, $shp:expr,
637                 $rb:expr, $sb:expr, $qb:expr, $pgb:expr, $shb:expr,
638                 $atlas:expr, $path_atlas:expr, $qs:expr, $index_binding:expr) => {
639                    flush_stream!($pass, $queue, &$streams.rect, $rp, $rb, $index_binding);
640                    flush_stream!($pass, $queue, &$streams.sdf, $sp, $sb, $index_binding);
641                    // Quad batch needs bind group
642                    if !$qb.is_empty() {
643                        let bg = match $qs {
644                            Some(QuadSource::PathAtlas) => {
645                                $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group)
646                            }
647                            _ => $atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
648                        };
649                        if let (Some(bind_group), Some((ib, _, _))) = (bg, $index_binding) {
650                            let bytes: &[u8] = bytemuck::cast_slice(&$qb);
651                            if let Some((vb, v_off, v_len)) = $streams.quad.write($queue, bytes) {
652                                let quads = ($qb.len() / 4) as u32;
653                                let index_count = quads * 6;
654                                let index_bytes = (index_count as u64) * 4;
655                                $pass.set_pipeline($qp);
656                                $pass.set_bind_group(0, bind_group, &[]);
657                                $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
658                                $pass.set_index_buffer(
659                                    ib.slice(0..index_bytes),
660                                    wgpu::IndexFormat::Uint32,
661                                );
662                                $pass.draw_indexed(0..index_count, 0, 0..1);
663                            }
664                        }
665                        $qb.clear();
666                    }
667                    // Gradient-filled path batch. Binds the SAME path
668                    // atlas texture bind group the solid path-quad batch
669                    // above uses (`$path_atlas`) — the gradient pipeline
670                    // reuses `quad_pipeline`'s group(0) layout, so the
671                    // bind group is interchangeable.
672                    if !$pgb.is_empty() {
673                        if let (Some(bind_group), Some((ib, _, _))) = (
674                            $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
675                            $index_binding,
676                        ) {
677                            let bytes: &[u8] = bytemuck::cast_slice(&$pgb);
678                            if let Some((vb, v_off, v_len)) =
679                                $streams.path_gradient.write($queue, bytes)
680                            {
681                                let quads = ($pgb.len() / 4) as u32;
682                                let index_count = quads * 6;
683                                let index_bytes = (index_count as u64) * 4;
684                                $pass.set_pipeline($pgp);
685                                $pass.set_bind_group(0, bind_group, &[]);
686                                $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
687                                $pass.set_index_buffer(
688                                    ib.slice(0..index_bytes),
689                                    wgpu::IndexFormat::Uint32,
690                                );
691                                $pass.draw_indexed(0..index_count, 0, 0..1);
692                            }
693                        }
694                        $pgb.clear();
695                    }
696                    flush_stream!($pass, $queue, &$streams.shadow, $shp, $shb, $index_binding);
697                    // Animated-quad procedural batch. Unlike the shared
698                    // atlas quad pipeline above, this always binds the
699                    // same uniform bind group (per-slot state read by
700                    // shader) so there's no source-switching. Accesses
701                    // `self.anim_proc_pipeline` / `.anim_uniform_bind_group`
702                    // and the local `anim_proc_batch` via macro hygiene —
703                    // all three are in scope inside `render()` at every
704                    // flush_all! call site.
705                    if !anim_proc_batch.is_empty()
706                        && let Some((ib, _, _)) = $index_binding
707                    {
708                        let bytes: &[u8] = bytemuck::cast_slice(&anim_proc_batch);
709                        if let Some((vb, v_off, v_len)) = $streams.anim_proc.write($queue, bytes) {
710                            let quads = (anim_proc_batch.len() / 4) as u32;
711                            let index_count = quads * 6;
712                            let index_bytes = (index_count as u64) * 4;
713                            $pass.set_pipeline(&self.anim_proc_pipeline);
714                            $pass.set_bind_group(0, &self.anim_uniform_bind_group, &[]);
715                            $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
716                            $pass.set_index_buffer(
717                                ib.slice(0..index_bytes),
718                                wgpu::IndexFormat::Uint32,
719                            );
720                            $pass.draw_indexed(0..index_count, 0, 0..1);
721                        }
722                        anim_proc_batch.clear();
723                    }
724                };
725            }
726
727            // Draw in painter's order. Outer loop iterates render
728            // segments — one segment per `RenderPass`. A blur Begin/End
729            // boundary opens a new segment. The pass lives in its own
730            // scope so the encoder borrow is released at each boundary
731            // (allowing the next pass open or any in-between Kawase
732            // work on the encoder).
733            let mut cmd_idx = 0;
734            while cmd_idx <= frame.draw_order.len() {
735                // Resolve current target. We `match` the intermediate
736                // handle vs. surface here; the resulting `target_view`
737                // lifetime ties to one of self.blur_pool / `view` arg.
738                let (target_view, load_op): (&wgpu::TextureView, wgpu::LoadOp<wgpu::Color>) = {
739                    let t = target_stack
740                        .last_mut()
741                        .expect("surface target always present");
742                    let v: &wgpu::TextureView = match t.intermediate {
743                        Some(h) => self.blur_pool.view(h),
744                        None => view,
745                    };
746                    let lo = if t.opened {
747                        wgpu::LoadOp::Load
748                    } else if t.intermediate.is_none() {
749                        wgpu::LoadOp::Clear(surface_clear_color)
750                    } else {
751                        wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
752                    };
753                    t.opened = true;
754                    viewport_width = t.viewport_w;
755                    viewport_height = t.viewport_h;
756                    (v, lo)
757                };
758
759                // Drain pending composites — these are blurred sub-tree
760                // results from nested blur scopes that finished while
761                // we weren't drawing into THIS target. They paint first
762                // in the new segment so subsequent commands stack on
763                // top of the blurred quad.
764                let composites_to_draw: Vec<PendingComposite> = std::mem::take(
765                    &mut target_stack
766                        .last_mut()
767                        .expect("target_stack always has the surface target")
768                        .pending_composites,
769                );
770
771                {
772                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
773                        label: Some("teksilo_segment_pass"),
774                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
775                            view: target_view,
776                            resolve_target: None,
777                            ops: wgpu::Operations {
778                                load: load_op,
779                                store: wgpu::StoreOp::Store,
780                            },
781                            depth_slice: None,
782                        })],
783                        depth_stencil_attachment: None,
784                        timestamp_writes: None,
785                        occlusion_query_set: None,
786                        multiview_mask: None,
787                    });
788
789                    // Composite pending blurred sub-trees first.
790                    for pc in &composites_to_draw {
791                        composite_blur_quad(
792                            &self.device,
793                            &self.queue,
794                            &mut pass,
795                            &self.blur_pool,
796                            &self.quad_pipeline,
797                            &self.quad_bind_group_layout,
798                            &self.blur_composite_sampler,
799                            &self.streams.quad,
800                            index_binding,
801                            pc.blurred_texture,
802                            pc.used_w,
803                            pc.used_h,
804                            pc.bucket_w,
805                            pc.bucket_h,
806                            pc.bounds,
807                            scale_factor,
808                            viewport_width,
809                            viewport_height,
810                        );
811                        // The composite uses the quad pipeline with a
812                        // fresh bind group → invalidate any cached
813                        // glyph/path-atlas binding for the next quad
814                        // batch.
815                        quad_source = None;
816                    }
817
818                    let pass = &mut pass;
819
820                    // Inner loop: process commands until we hit a blur
821                    // boundary or run out.
822                    while cmd_idx < frame.draw_order.len() {
823                        let cmd = &frame.draw_order[cmd_idx];
824                        if matches!(
825                            cmd,
826                            teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
827                                | teksilo_canvas::DrawCommand::EndBlurredSubtree
828                        ) {
829                            break;
830                        }
831                        match cmd {
832                            teksilo_canvas::DrawCommand::Decoration(idx) => {
833                                flush_all!(
834                                    pass,
835                                    &self.queue,
836                                    self.streams,
837                                    &self.rect_pipeline,
838                                    &self.sdf_pipeline,
839                                    &self.quad_pipeline,
840                                    &self.path_gradient_pipeline,
841                                    &self.shadow_pipeline,
842                                    rect_batch,
843                                    sdf_batch,
844                                    quad_batch,
845                                    path_gradient_batch,
846                                    shadow_batch,
847                                    self.atlas_texture,
848                                    self.path_atlas_texture,
849                                    quad_source,
850                                    index_binding
851                                );
852                                quad_source = None;
853                                let Some(rect) = frame.decorations.get(*idx) else {
854                                    continue;
855                                };
856                                let verts = RectVertex::from_decoration(rect, scale_factor);
857                                for v in &verts {
858                                    let tp = apply_transform_pixel(v.position, &current_transform);
859                                    rect_batch.push(RectVertex {
860                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
861                                        color: [
862                                            v.color[0],
863                                            v.color[1],
864                                            v.color[2],
865                                            v.color[3] * current_opacity,
866                                        ],
867                                    });
868                                }
869                            }
870                            teksilo_canvas::DrawCommand::CosmeticLine(idx) => {
871                                flush_all!(
872                                    pass,
873                                    &self.queue,
874                                    self.streams,
875                                    &self.rect_pipeline,
876                                    &self.sdf_pipeline,
877                                    &self.quad_pipeline,
878                                    &self.path_gradient_pipeline,
879                                    &self.shadow_pipeline,
880                                    rect_batch,
881                                    sdf_batch,
882                                    quad_batch,
883                                    path_gradient_batch,
884                                    shadow_batch,
885                                    self.atlas_texture,
886                                    self.path_atlas_texture,
887                                    quad_source,
888                                    index_binding
889                                );
890                                quad_source = None;
891                                let Some(line) = frame.cosmetic_lines.get(*idx) else {
892                                    continue;
893                                };
894                                // Transform the endpoints (premultiplied by the
895                                // HiDPI scale_factor) through the active
896                                // transform, then apply a device-pixel thickness
897                                // that does NOT scale with the transform's zoom.
898                                let p0 = apply_transform_pixel(
899                                    [line.from[0] * scale_factor, line.from[1] * scale_factor],
900                                    &current_transform,
901                                );
902                                let p1 = apply_transform_pixel(
903                                    [line.to[0] * scale_factor, line.to[1] * scale_factor],
904                                    &current_transform,
905                                );
906                                let thickness = (line.width * scale_factor).max(1.0);
907                                let half = thickness * 0.5;
908                                let dx = p1[0] - p0[0];
909                                let dy = p1[1] - p0[1];
910                                let len = (dx * dx + dy * dy).sqrt();
911                                if len < 1e-3 {
912                                    continue;
913                                }
914                                // Perpendicular unit normal in device space.
915                                let nx = -dy / len;
916                                let ny = dx / len;
917                                // Pixel-snap axis-aligned lines (edge-aligned
918                                // center) for crispness; leave diagonals as-is.
919                                let (mut a0, mut a1) = (p0, p1);
920                                if dy.abs() < 0.5 {
921                                    let cy = ((p0[1] + p1[1]) * 0.5 - half).round() + half;
922                                    a0 = [p0[0], cy];
923                                    a1 = [p1[0], cy];
924                                } else if dx.abs() < 0.5 {
925                                    let cx = ((p0[0] + p1[0]) * 0.5 - half).round() + half;
926                                    a0 = [cx, p0[1]];
927                                    a1 = [cx, p1[1]];
928                                }
929                                let lin = crate::vertex::srgb_to_linear_rgba(line.color);
930                                let color = [lin[0], lin[1], lin[2], lin[3] * current_opacity];
931                                let corners = [
932                                    [a0[0] + nx * half, a0[1] + ny * half],
933                                    [a1[0] + nx * half, a1[1] + ny * half],
934                                    [a1[0] - nx * half, a1[1] - ny * half],
935                                    [a0[0] - nx * half, a0[1] - ny * half],
936                                ];
937                                for pos in corners {
938                                    rect_batch.push(RectVertex {
939                                        position: pixel_to_ndc(
940                                            pos,
941                                            viewport_width,
942                                            viewport_height,
943                                        ),
944                                        color,
945                                    });
946                                }
947                            }
948                            teksilo_canvas::DrawCommand::Shape(idx) => {
949                                flush_all!(
950                                    pass,
951                                    &self.queue,
952                                    self.streams,
953                                    &self.rect_pipeline,
954                                    &self.sdf_pipeline,
955                                    &self.quad_pipeline,
956                                    &self.path_gradient_pipeline,
957                                    &self.shadow_pipeline,
958                                    rect_batch,
959                                    sdf_batch,
960                                    quad_batch,
961                                    path_gradient_batch,
962                                    shadow_batch,
963                                    self.atlas_texture,
964                                    self.path_atlas_texture,
965                                    quad_source,
966                                    index_binding
967                                );
968                                quad_source = None;
969                                let Some(shape) = frame.shapes.get(*idx) else {
970                                    continue;
971                                };
972                                // Cosmetic (device-space) borders hold a
973                                // constant device-pixel width under zoom: the
974                                // body still scales via `current_transform`, but
975                                // the SDF stroke param is divided by the active
976                                // zoom (the uniform scale of the linear part,
977                                // which carries no scale_factor — see
978                                // SetTransform). Fills + logical strokes are
979                                // unchanged.
980                                let verts = if shape.stroke_space
981                                    == teksilo_canvas::StrokeSpace::Device
982                                    && shape.stroke_width > 0.0
983                                {
984                                    // Uniform scale of the linear part = view
985                                    // zoom (no scale_factor — it lives only in
986                                    // the translation column). `from_shape_quad_cosmetic`
987                                    // applies the divide-by-zero floor.
988                                    let zoom = current_transform.m[0].hypot(current_transform.m[1]);
989                                    SdfVertex::from_shape_quad_cosmetic(shape, scale_factor, zoom)
990                                } else {
991                                    SdfVertex::from_shape_quad(shape, scale_factor)
992                                };
993                                for v in &verts {
994                                    let tp = apply_transform_pixel(v.position, &current_transform);
995                                    sdf_batch.push(SdfVertex {
996                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
997                                        color: [
998                                            v.color[0],
999                                            v.color[1],
1000                                            v.color[2],
1001                                            v.color[3] * current_opacity,
1002                                        ],
1003                                        ..*v
1004                                    });
1005                                }
1006                            }
1007                            teksilo_canvas::DrawCommand::Glyph(idx) => {
1008                                // Only flush when the quad source changes — consecutive
1009                                // glyphs batch into one draw call.
1010                                if quad_source != Some(QuadSource::GlyphAtlas) {
1011                                    flush_all!(
1012                                        pass,
1013                                        &self.queue,
1014                                        self.streams,
1015                                        &self.rect_pipeline,
1016                                        &self.sdf_pipeline,
1017                                        &self.quad_pipeline,
1018                                        &self.path_gradient_pipeline,
1019                                        &self.shadow_pipeline,
1020                                        rect_batch,
1021                                        sdf_batch,
1022                                        quad_batch,
1023                                        path_gradient_batch,
1024                                        shadow_batch,
1025                                        self.atlas_texture,
1026                                        self.path_atlas_texture,
1027                                        quad_source,
1028                                        index_binding
1029                                    );
1030                                    quad_source = Some(QuadSource::GlyphAtlas);
1031                                }
1032                                if let Some(atlas) = &self.atlas_texture {
1033                                    let Some(glyph) = frame.glyphs.get(*idx) else {
1034                                        continue;
1035                                    };
1036                                    // Transform is applied (and 1:1 quads
1037                                    // pixel-snapped) inside the constructor.
1038                                    let verts = QuadVertex::from_glyph_quad_transformed(
1039                                        glyph,
1040                                        scale_factor,
1041                                        atlas.width,
1042                                        atlas.height,
1043                                        &current_transform,
1044                                    );
1045                                    for v in &verts {
1046                                        quad_batch.push(QuadVertex {
1047                                            position: pixel_to_ndc(
1048                                                v.position,
1049                                                viewport_width,
1050                                                viewport_height,
1051                                            ),
1052                                            color: [
1053                                                v.color[0],
1054                                                v.color[1],
1055                                                v.color[2],
1056                                                v.color[3] * current_opacity,
1057                                            ],
1058                                            ..*v
1059                                        });
1060                                    }
1061                                }
1062                            }
1063                            teksilo_canvas::DrawCommand::Shadow(idx) => {
1064                                flush_all!(
1065                                    pass,
1066                                    &self.queue,
1067                                    self.streams,
1068                                    &self.rect_pipeline,
1069                                    &self.sdf_pipeline,
1070                                    &self.quad_pipeline,
1071                                    &self.path_gradient_pipeline,
1072                                    &self.shadow_pipeline,
1073                                    rect_batch,
1074                                    sdf_batch,
1075                                    quad_batch,
1076                                    path_gradient_batch,
1077                                    shadow_batch,
1078                                    self.atlas_texture,
1079                                    self.path_atlas_texture,
1080                                    quad_source,
1081                                    index_binding
1082                                );
1083                                quad_source = None;
1084                                let Some(shadow) = frame.shadows.get(*idx) else {
1085                                    continue;
1086                                };
1087                                let verts = ShadowVertex::from_shadow_quad(shadow, scale_factor);
1088                                for v in &verts {
1089                                    let tp = apply_transform_pixel(v.position, &current_transform);
1090                                    shadow_batch.push(ShadowVertex {
1091                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
1092                                        shadow_color: [
1093                                            v.shadow_color[0],
1094                                            v.shadow_color[1],
1095                                            v.shadow_color[2],
1096                                            v.shadow_color[3] * current_opacity,
1097                                        ],
1098                                        ..*v
1099                                    });
1100                                }
1101                            }
1102                            teksilo_canvas::DrawCommand::Image(idx) => {
1103                                // Images use per-image bind groups — flush and draw individually
1104                                flush_all!(
1105                                    pass,
1106                                    &self.queue,
1107                                    self.streams,
1108                                    &self.rect_pipeline,
1109                                    &self.sdf_pipeline,
1110                                    &self.quad_pipeline,
1111                                    &self.path_gradient_pipeline,
1112                                    &self.shadow_pipeline,
1113                                    rect_batch,
1114                                    sdf_batch,
1115                                    quad_batch,
1116                                    path_gradient_batch,
1117                                    shadow_batch,
1118                                    self.atlas_texture,
1119                                    self.path_atlas_texture,
1120                                    quad_source,
1121                                    index_binding
1122                                );
1123                                quad_source = None;
1124                                let Some(image) = frame.images.get(*idx) else {
1125                                    continue;
1126                                };
1127                                self.draw_image(
1128                                    pass,
1129                                    image,
1130                                    scale_factor,
1131                                    viewport_width,
1132                                    viewport_height,
1133                                    current_opacity,
1134                                    &current_transform,
1135                                    index_binding,
1136                                );
1137                            }
1138                            teksilo_canvas::DrawCommand::Path(idx) => {
1139                                flush_all!(
1140                                    pass,
1141                                    &self.queue,
1142                                    self.streams,
1143                                    &self.rect_pipeline,
1144                                    &self.sdf_pipeline,
1145                                    &self.quad_pipeline,
1146                                    &self.path_gradient_pipeline,
1147                                    &self.shadow_pipeline,
1148                                    rect_batch,
1149                                    sdf_batch,
1150                                    quad_batch,
1151                                    path_gradient_batch,
1152                                    shadow_batch,
1153                                    self.atlas_texture,
1154                                    self.path_atlas_texture,
1155                                    quad_source,
1156                                    index_binding
1157                                );
1158                                quad_source = None;
1159                                if let Some(Some(placement)) = path_placements.get(*idx) {
1160                                    let Some(entry) = frame.paths.get(*idx) else {
1161                                        continue;
1162                                    };
1163                                    let Some(path_atlas) = self.path_atlas_texture.as_ref() else {
1164                                        continue;
1165                                    };
1166                                    if matches!(entry.paint_data, teksilo_canvas::PaintData::Solid)
1167                                    {
1168                                        // Solid fill or solid stroke: the lean
1169                                        // quad_pipeline, tinted by entry.color.
1170                                        // (A gradient *stroke* takes the branch
1171                                        // below — the pipeline choice follows the
1172                                        // paint, not fill-vs-stroke; the coverage
1173                                        // mask in the atlas is already whichever
1174                                        // one this entry rasterized.)
1175                                        quad_source = Some(QuadSource::PathAtlas);
1176                                        let verts = path_quad_verts(
1177                                            entry,
1178                                            placement,
1179                                            path_atlas.width,
1180                                            path_atlas.height,
1181                                            current_opacity,
1182                                            &current_transform,
1183                                        );
1184                                        for v in &verts {
1185                                            quad_batch.push(QuadVertex {
1186                                                position: pixel_to_ndc(
1187                                                    v.position,
1188                                                    viewport_width,
1189                                                    viewport_height,
1190                                                ),
1191                                                ..*v
1192                                            });
1193                                        }
1194                                    } else {
1195                                        // Gradient fill: the dedicated
1196                                        // path_gradient pipeline, which
1197                                        // samples the SAME atlas coverage
1198                                        // mask but computes an analytic
1199                                        // gradient color instead of a flat
1200                                        // tint.
1201                                        let verts = path_gradient_quad_verts(
1202                                            entry,
1203                                            placement,
1204                                            scale_factor,
1205                                            path_atlas.width,
1206                                            path_atlas.height,
1207                                            current_opacity,
1208                                            &current_transform,
1209                                        );
1210                                        for v in &verts {
1211                                            path_gradient_batch.push(
1212                                                crate::vertex::PathGradientVertex {
1213                                                    position: pixel_to_ndc(
1214                                                        v.position,
1215                                                        viewport_width,
1216                                                        viewport_height,
1217                                                    ),
1218                                                    ..*v
1219                                                },
1220                                            );
1221                                        }
1222                                    }
1223                                }
1224                            }
1225                            // --- State changes flush all batches ---
1226                            teksilo_canvas::DrawCommand::SetClip(rect) => {
1227                                flush_all!(
1228                                    pass,
1229                                    &self.queue,
1230                                    self.streams,
1231                                    &self.rect_pipeline,
1232                                    &self.sdf_pipeline,
1233                                    &self.quad_pipeline,
1234                                    &self.path_gradient_pipeline,
1235                                    &self.shadow_pipeline,
1236                                    rect_batch,
1237                                    sdf_batch,
1238                                    quad_batch,
1239                                    path_gradient_batch,
1240                                    shadow_batch,
1241                                    self.atlas_texture,
1242                                    self.path_atlas_texture,
1243                                    quad_source,
1244                                    index_binding
1245                                );
1246                                quad_source = None;
1247                                // Apply the current transform stack to the
1248                                // clip rect. Without this, a clip emitted
1249                                // inside a SceneView's view-transform scope
1250                                // (e.g. ScrollArea or nested SceneView as
1251                                // a heavyweight scene_rect widget) would
1252                                // mask the rendered content to the rect's
1253                                // PRE-transform position — the contents
1254                                // visually pan/zoom with the outer view but
1255                                // the clip mask stays fixed in screen
1256                                // space, "eating" the widget as the user
1257                                // pans or zooms out.
1258                                //
1259                                // Rotation-free transforms (the common case
1260                                // for SceneView pan + zoom) produce an
1261                                // axis-aligned transformed rect; for rotated
1262                                // transforms we take the AABB of the four
1263                                // corners, which over-clips slightly but
1264                                // remains correct for visibility.
1265                                let p_tl =
1266                                    apply_transform_pixel([rect.x, rect.y], &current_transform);
1267                                let p_tr = apply_transform_pixel(
1268                                    [rect.x + rect.width, rect.y],
1269                                    &current_transform,
1270                                );
1271                                let p_bl = apply_transform_pixel(
1272                                    [rect.x, rect.y + rect.height],
1273                                    &current_transform,
1274                                );
1275                                let p_br = apply_transform_pixel(
1276                                    [rect.x + rect.width, rect.y + rect.height],
1277                                    &current_transform,
1278                                );
1279                                let min_x = p_tl[0].min(p_tr[0]).min(p_bl[0]).min(p_br[0]);
1280                                let min_y = p_tl[1].min(p_tr[1]).min(p_bl[1]).min(p_br[1]);
1281                                let max_x = p_tl[0].max(p_tr[0]).max(p_bl[0]).max(p_br[0]);
1282                                let max_y = p_tl[1].max(p_tr[1]).max(p_bl[1]).max(p_br[1]);
1283                                let x = (min_x * scale_factor).max(0.0) as u32;
1284                                let y = (min_y * scale_factor).max(0.0) as u32;
1285                                let w = ((max_x - min_x) * scale_factor).ceil().max(0.0) as u32;
1286                                let h = ((max_y - min_y) * scale_factor).ceil().max(0.0) as u32;
1287                                // Clamp to viewport — wgpu requires x+w <= width, y+h <= height.
1288                                let x = x.min(viewport_width);
1289                                let y = y.min(viewport_height);
1290                                let w = w.min(viewport_width.saturating_sub(x));
1291                                let h = h.min(viewport_height.saturating_sub(y));
1292                                let clipped = if let Some(&[cx, cy, cw, ch]) = clip_stack.last() {
1293                                    let ix = x.max(cx);
1294                                    let iy = y.max(cy);
1295                                    let ir = (x + w).min(cx + cw);
1296                                    let ib = (y + h).min(cy + ch);
1297                                    [ix, iy, ir.saturating_sub(ix), ib.saturating_sub(iy)]
1298                                } else {
1299                                    [x, y, w, h]
1300                                };
1301                                clip_stack.push(clipped);
1302                                pass.set_scissor_rect(
1303                                    clipped[0], clipped[1], clipped[2], clipped[3],
1304                                );
1305                            }
1306                            teksilo_canvas::DrawCommand::ClearClip => {
1307                                flush_all!(
1308                                    pass,
1309                                    &self.queue,
1310                                    self.streams,
1311                                    &self.rect_pipeline,
1312                                    &self.sdf_pipeline,
1313                                    &self.quad_pipeline,
1314                                    &self.path_gradient_pipeline,
1315                                    &self.shadow_pipeline,
1316                                    rect_batch,
1317                                    sdf_batch,
1318                                    quad_batch,
1319                                    path_gradient_batch,
1320                                    shadow_batch,
1321                                    self.atlas_texture,
1322                                    self.path_atlas_texture,
1323                                    quad_source,
1324                                    index_binding
1325                                );
1326                                quad_source = None;
1327                                clip_stack.pop();
1328                                if let Some(&[x, y, w, h]) = clip_stack.last() {
1329                                    pass.set_scissor_rect(x, y, w, h);
1330                                } else {
1331                                    pass.set_scissor_rect(0, 0, viewport_width, viewport_height);
1332                                }
1333                            }
1334                            teksilo_canvas::DrawCommand::SetOpacity(opacity) => {
1335                                flush_all!(
1336                                    pass,
1337                                    &self.queue,
1338                                    self.streams,
1339                                    &self.rect_pipeline,
1340                                    &self.sdf_pipeline,
1341                                    &self.quad_pipeline,
1342                                    &self.path_gradient_pipeline,
1343                                    &self.shadow_pipeline,
1344                                    rect_batch,
1345                                    sdf_batch,
1346                                    quad_batch,
1347                                    path_gradient_batch,
1348                                    shadow_batch,
1349                                    self.atlas_texture,
1350                                    self.path_atlas_texture,
1351                                    quad_source,
1352                                    index_binding
1353                                );
1354                                quad_source = None;
1355                                opacity_stack.push(current_opacity);
1356                                current_opacity *= opacity;
1357                            }
1358                            teksilo_canvas::DrawCommand::RestoreOpacity => {
1359                                flush_all!(
1360                                    pass,
1361                                    &self.queue,
1362                                    self.streams,
1363                                    &self.rect_pipeline,
1364                                    &self.sdf_pipeline,
1365                                    &self.quad_pipeline,
1366                                    &self.path_gradient_pipeline,
1367                                    &self.shadow_pipeline,
1368                                    rect_batch,
1369                                    sdf_batch,
1370                                    quad_batch,
1371                                    path_gradient_batch,
1372                                    shadow_batch,
1373                                    self.atlas_texture,
1374                                    self.path_atlas_texture,
1375                                    quad_source,
1376                                    index_binding
1377                                );
1378                                quad_source = None;
1379                                current_opacity = opacity_stack.pop().unwrap_or(1.0);
1380                            }
1381                            teksilo_canvas::DrawCommand::Rasterized(_) => {}
1382                            teksilo_canvas::DrawCommand::AnimatedQuad(idx) => {
1383                                let Some(draw) = frame.animated_quads.get(*idx) else {
1384                                    continue;
1385                                };
1386                                // Flush every other pipeline first so painter's
1387                                // order is preserved across pipeline boundaries.
1388                                flush_all!(
1389                                    pass,
1390                                    &self.queue,
1391                                    self.streams,
1392                                    &self.rect_pipeline,
1393                                    &self.sdf_pipeline,
1394                                    &self.quad_pipeline,
1395                                    &self.path_gradient_pipeline,
1396                                    &self.shadow_pipeline,
1397                                    rect_batch,
1398                                    sdf_batch,
1399                                    quad_batch,
1400                                    path_gradient_batch,
1401                                    shadow_batch,
1402                                    self.atlas_texture,
1403                                    self.path_atlas_texture,
1404                                    quad_source,
1405                                    index_binding
1406                                );
1407                                quad_source = None;
1408                                match &draw.class {
1409                                    teksilo_canvas::AnimatedQuadClass::Procedural => {
1410                                        let verts =
1411                                            AnimQuadVertex::from_animated_quad(draw, scale_factor);
1412                                        for v in &verts {
1413                                            let tp = apply_transform_pixel(
1414                                                v.position,
1415                                                &current_transform,
1416                                            );
1417                                            anim_proc_batch.push(AnimQuadVertex {
1418                                                position: pixel_to_ndc(
1419                                                    tp,
1420                                                    viewport_width,
1421                                                    viewport_height,
1422                                                ),
1423                                                uv: v.uv,
1424                                                slot: v.slot,
1425                                                _pad: v._pad,
1426                                            });
1427                                        }
1428                                    }
1429                                    teksilo_canvas::AnimatedQuadClass::Sprite { image_name } => {
1430                                        // Sprite quads need a per-atlas bind
1431                                        // group, so each draws individually —
1432                                        // same shape as the static Image path.
1433                                        // Typical scene has ~1 animated sprite
1434                                        // icon at a time, so batching is moot.
1435                                        let Some(atlas_bg) =
1436                                            self.image_manager.get_bind_group(image_name)
1437                                        else {
1438                                            continue;
1439                                        };
1440                                        let verts =
1441                                            AnimQuadVertex::from_animated_quad(draw, scale_factor);
1442                                        let mut ndc_verts = [AnimQuadVertex {
1443                                            position: [0.0; 2],
1444                                            uv: [0.0; 2],
1445                                            slot: 0,
1446                                            _pad: 0,
1447                                        };
1448                                            4];
1449                                        for (i, v) in verts.iter().enumerate() {
1450                                            let tp = apply_transform_pixel(
1451                                                v.position,
1452                                                &current_transform,
1453                                            );
1454                                            ndc_verts[i] = AnimQuadVertex {
1455                                                position: pixel_to_ndc(
1456                                                    tp,
1457                                                    viewport_width,
1458                                                    viewport_height,
1459                                                ),
1460                                                uv: v.uv,
1461                                                slot: v.slot,
1462                                                _pad: v._pad,
1463                                            };
1464                                        }
1465                                        let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1466                                        if let (Some((vb, v_off, v_len)), Some((ib, _, _))) = (
1467                                            self.streams.anim_proc.write(&self.queue, bytes),
1468                                            index_binding,
1469                                        ) {
1470                                            let index_bytes: u64 = 6 * 4;
1471                                            pass.set_pipeline(&self.anim_sprite_pipeline);
1472                                            pass.set_bind_group(
1473                                                0,
1474                                                &self.anim_uniform_bind_group,
1475                                                &[],
1476                                            );
1477                                            pass.set_bind_group(1, atlas_bg, &[]);
1478                                            pass.set_vertex_buffer(
1479                                                0,
1480                                                vb.slice(v_off..v_off + v_len),
1481                                            );
1482                                            pass.set_index_buffer(
1483                                                ib.slice(0..index_bytes),
1484                                                wgpu::IndexFormat::Uint32,
1485                                            );
1486                                            pass.draw_indexed(0..6, 0, 0..1);
1487                                        }
1488                                    }
1489                                }
1490                            }
1491                            teksilo_canvas::DrawCommand::SetBlendMode(mode) => {
1492                                blend_stack.push(current_blend);
1493                                current_blend = *mode;
1494                            }
1495                            teksilo_canvas::DrawCommand::RestoreBlendMode => {
1496                                current_blend = blend_stack
1497                                    .pop()
1498                                    .unwrap_or(teksilo_canvas::BlendMode::Normal);
1499                            }
1500                            teksilo_canvas::DrawCommand::SetTransform(t) => {
1501                                flush_all!(
1502                                    pass,
1503                                    &self.queue,
1504                                    self.streams,
1505                                    &self.rect_pipeline,
1506                                    &self.sdf_pipeline,
1507                                    &self.quad_pipeline,
1508                                    &self.path_gradient_pipeline,
1509                                    &self.shadow_pipeline,
1510                                    rect_batch,
1511                                    sdf_batch,
1512                                    quad_batch,
1513                                    path_gradient_batch,
1514                                    shadow_batch,
1515                                    self.atlas_texture,
1516                                    self.path_atlas_texture,
1517                                    quad_source,
1518                                    index_binding
1519                                );
1520                                quad_source = None;
1521                                // Widgets author transforms in logical pixels, but
1522                                // vertices arrive pre-multiplied by scale_factor (HiDPI
1523                                // device pixels). Scale the translation column so the
1524                                // pivot lands at the same physical point in either
1525                                // coordinate space.
1526                                let device_t = Transform2D {
1527                                    m: [
1528                                        t.m[0],
1529                                        t.m[1],
1530                                        t.m[2],
1531                                        t.m[3],
1532                                        t.m[4] * scale_factor,
1533                                        t.m[5] * scale_factor,
1534                                    ],
1535                                };
1536                                // Compose with the current transform-stack top so a
1537                                // widget's canvas-local transform respects any wrapper
1538                                // transform pushed by the render walker. With an
1539                                // identity stack top this is identical to the old
1540                                // "absolute" semantics — backwards compatible for any
1541                                // widget not under a transform scope.
1542                                let stack_top = transform_stack
1543                                    .last()
1544                                    .copied()
1545                                    .unwrap_or(Transform2D::IDENTITY);
1546                                current_transform = device_t.then(&stack_top);
1547                            }
1548                            teksilo_canvas::DrawCommand::PushTransform(t) => {
1549                                flush_all!(
1550                                    pass,
1551                                    &self.queue,
1552                                    self.streams,
1553                                    &self.rect_pipeline,
1554                                    &self.sdf_pipeline,
1555                                    &self.quad_pipeline,
1556                                    &self.path_gradient_pipeline,
1557                                    &self.shadow_pipeline,
1558                                    rect_batch,
1559                                    sdf_batch,
1560                                    quad_batch,
1561                                    path_gradient_batch,
1562                                    shadow_batch,
1563                                    self.atlas_texture,
1564                                    self.path_atlas_texture,
1565                                    quad_source,
1566                                    index_binding
1567                                );
1568                                quad_source = None;
1569                                // See SetTransform: scale the translation column to
1570                                // device pixels before composing.
1571                                let device_t = Transform2D {
1572                                    m: [
1573                                        t.m[0],
1574                                        t.m[1],
1575                                        t.m[2],
1576                                        t.m[3],
1577                                        t.m[4] * scale_factor,
1578                                        t.m[5] * scale_factor,
1579                                    ],
1580                                };
1581                                let prev_top = transform_stack
1582                                    .last()
1583                                    .copied()
1584                                    .unwrap_or(Transform2D::IDENTITY);
1585                                let new_top = device_t.then(&prev_top);
1586                                transform_stack.push(new_top);
1587                                current_transform = new_top;
1588                            }
1589                            teksilo_canvas::DrawCommand::PopTransform => {
1590                                flush_all!(
1591                                    pass,
1592                                    &self.queue,
1593                                    self.streams,
1594                                    &self.rect_pipeline,
1595                                    &self.sdf_pipeline,
1596                                    &self.quad_pipeline,
1597                                    &self.path_gradient_pipeline,
1598                                    &self.shadow_pipeline,
1599                                    rect_batch,
1600                                    sdf_batch,
1601                                    quad_batch,
1602                                    path_gradient_batch,
1603                                    shadow_batch,
1604                                    self.atlas_texture,
1605                                    self.path_atlas_texture,
1606                                    quad_source,
1607                                    index_binding
1608                                );
1609                                quad_source = None;
1610                                if transform_stack.len() > 1 {
1611                                    transform_stack.pop();
1612                                }
1613                                current_transform = transform_stack
1614                                    .last()
1615                                    .copied()
1616                                    .unwrap_or(Transform2D::IDENTITY);
1617                            }
1618                            teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1619                            | teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1620                                // Unreachable — the inner-loop guard above
1621                                // breaks before we enter the match for these.
1622                                unreachable!("blur boundaries are handled at the segment level");
1623                            }
1624                        }
1625                        cmd_idx += 1;
1626                    }
1627
1628                    // End-of-segment flush.
1629                    flush_all!(
1630                        pass,
1631                        &self.queue,
1632                        self.streams,
1633                        &self.rect_pipeline,
1634                        &self.sdf_pipeline,
1635                        &self.quad_pipeline,
1636                        &self.path_gradient_pipeline,
1637                        &self.shadow_pipeline,
1638                        rect_batch,
1639                        sdf_batch,
1640                        quad_batch,
1641                        path_gradient_batch,
1642                        shadow_batch,
1643                        self.atlas_texture,
1644                        self.path_atlas_texture,
1645                        quad_source,
1646                        index_binding
1647                    );
1648                    quad_source = None;
1649                } // pass dropped here, encoder borrow released
1650
1651                // Boundary handling. EOF, Begin, or End.
1652                if cmd_idx >= frame.draw_order.len() {
1653                    break;
1654                }
1655                match &frame.draw_order[cmd_idx] {
1656                    teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
1657                        // Allocate intermediate sized to bounds × scale.
1658                        let device_w = (bounds.width * scale_factor).ceil().max(1.0) as u32;
1659                        let device_h = (bounds.height * scale_factor).ceil().max(1.0) as u32;
1660                        let intermediate = self.blur_pool.acquire(&self.device, device_w, device_h);
1661                        let (bucket_w, bucket_h) = self.blur_pool.dimensions(intermediate);
1662
1663                        // Push a translation so the subtree renders at
1664                        // (0, 0) of the intermediate. Device-pixel
1665                        // translation since vertices arrive pre-scaled
1666                        // (see SetTransform handler for the same trick).
1667                        let translate = Transform2D {
1668                            m: [
1669                                1.0,
1670                                0.0,
1671                                0.0,
1672                                1.0,
1673                                -bounds.x * scale_factor,
1674                                -bounds.y * scale_factor,
1675                            ],
1676                        };
1677                        let prev_top = transform_stack
1678                            .last()
1679                            .copied()
1680                            .unwrap_or(Transform2D::IDENTITY);
1681                        let new_top = translate.then(&prev_top);
1682                        transform_stack.push(new_top);
1683                        current_transform = new_top;
1684
1685                        target_stack.push(ActiveTarget {
1686                            intermediate: Some(intermediate),
1687                            viewport_w: bucket_w,
1688                            viewport_h: bucket_h,
1689                            opened: false,
1690                            pending_composites: Vec::new(),
1691                            blur_bounds: Some(*bounds),
1692                            blur_radius_logical: Some(*radius),
1693                            used_w: Some(device_w),
1694                            used_h: Some(device_h),
1695                            bucket_w: Some(bucket_w),
1696                            bucket_h: Some(bucket_h),
1697                        });
1698                    }
1699                    teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1700                        let scope = target_stack
1701                            .pop()
1702                            .expect("EndBlurredSubtree without matching Begin");
1703                        debug_assert!(
1704                            scope.intermediate.is_some(),
1705                            "End popped the surface (impossible if walker is balanced)"
1706                        );
1707                        let intermediate = scope
1708                            .intermediate
1709                            .expect("blur scope intermediate set in BeginBlurredSubtree");
1710                        let bounds = scope
1711                            .blur_bounds
1712                            .expect("blur scope bounds set in BeginBlurredSubtree");
1713                        let radius = scope
1714                            .blur_radius_logical
1715                            .expect("blur scope radius set in BeginBlurredSubtree");
1716                        let used_w = scope
1717                            .used_w
1718                            .expect("blur scope used_w set in BeginBlurredSubtree");
1719                        let used_h = scope
1720                            .used_h
1721                            .expect("blur scope used_h set in BeginBlurredSubtree");
1722                        let bucket_w = scope
1723                            .bucket_w
1724                            .expect("blur scope bucket_w set in BeginBlurredSubtree");
1725                        let bucket_h = scope
1726                            .bucket_h
1727                            .expect("blur scope bucket_h set in BeginBlurredSubtree");
1728
1729                        // Pop the translation pushed in Begin.
1730                        if transform_stack.len() > 1 {
1731                            transform_stack.pop();
1732                        }
1733                        current_transform = transform_stack
1734                            .last()
1735                            .copied()
1736                            .unwrap_or(Transform2D::IDENTITY);
1737
1738                        // Run dual-Kawase. The chain begins its own
1739                        // sub-passes against pool textures — the outer
1740                        // segment's pass is already dropped.
1741                        let blurred = run_kawase_chain(
1742                            &self.device,
1743                            &self.queue,
1744                            &mut encoder,
1745                            &mut self.blur_pool,
1746                            &self.blur_pipelines,
1747                            intermediate,
1748                            used_w,
1749                            used_h,
1750                            bucket_w,
1751                            bucket_h,
1752                            radius * scale_factor,
1753                        );
1754
1755                        // Schedule a composite into the parent target's
1756                        // next segment open.
1757                        target_stack
1758                            .last_mut()
1759                            .expect("target_stack always has the surface target")
1760                            .pending_composites
1761                            .push(PendingComposite {
1762                                blurred_texture: blurred.texture,
1763                                used_w: blurred.used_w,
1764                                used_h: blurred.used_h,
1765                                bucket_w: blurred.bucket_w,
1766                                bucket_h: blurred.bucket_h,
1767                                bounds,
1768                            });
1769                    }
1770                    _ => unreachable!("inner loop only breaks on Begin/End"),
1771                }
1772                cmd_idx += 1;
1773            }
1774
1775            debug_assert!(
1776                target_stack.len() == 1,
1777                "target_stack not balanced at EOF — unmatched Begin/End in walker output"
1778            );
1779            // The remaining surface target may still have a pending
1780            // composite (an outermost blur scope ending at end-of-frame
1781            // with no further commands). Drain it in one final pass.
1782            let final_composites = std::mem::take(
1783                &mut target_stack
1784                    .last_mut()
1785                    .expect("target_stack always has the surface target")
1786                    .pending_composites,
1787            );
1788            if !final_composites.is_empty() {
1789                let surface = target_stack
1790                    .last_mut()
1791                    .expect("target_stack always has the surface target");
1792                let load_op = if surface.opened {
1793                    wgpu::LoadOp::Load
1794                } else {
1795                    wgpu::LoadOp::Clear(surface_clear_color)
1796                };
1797                surface.opened = true;
1798                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1799                    label: Some("teksilo_final_composite_pass"),
1800                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1801                        view,
1802                        resolve_target: None,
1803                        ops: wgpu::Operations {
1804                            load: load_op,
1805                            store: wgpu::StoreOp::Store,
1806                        },
1807                        depth_slice: None,
1808                    })],
1809                    depth_stencil_attachment: None,
1810                    timestamp_writes: None,
1811                    occlusion_query_set: None,
1812                    multiview_mask: None,
1813                });
1814                for pc in &final_composites {
1815                    composite_blur_quad(
1816                        &self.device,
1817                        &self.queue,
1818                        &mut pass,
1819                        &self.blur_pool,
1820                        &self.quad_pipeline,
1821                        &self.quad_bind_group_layout,
1822                        &self.blur_composite_sampler,
1823                        &self.streams.quad,
1824                        index_binding,
1825                        pc.blurred_texture,
1826                        pc.used_w,
1827                        pc.used_h,
1828                        pc.bucket_w,
1829                        pc.bucket_h,
1830                        pc.bounds,
1831                        scale_factor,
1832                        viewport_width,
1833                        viewport_height,
1834                    );
1835                }
1836            } else if !target_stack
1837                .last()
1838                .expect("target_stack always has the surface target")
1839                .opened
1840            {
1841                // Empty frame — open one pass to apply the clear.
1842                let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1843                    label: Some("teksilo_empty_clear_pass"),
1844                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1845                        view,
1846                        resolve_target: None,
1847                        ops: wgpu::Operations {
1848                            load: wgpu::LoadOp::Clear(surface_clear_color),
1849                            store: wgpu::StoreOp::Store,
1850                        },
1851                        depth_slice: None,
1852                    })],
1853                    depth_stencil_attachment: None,
1854                    timestamp_writes: None,
1855                    occlusion_query_set: None,
1856                    multiview_mask: None,
1857                });
1858            }
1859        }
1860
1861        self.queue.submit(std::iter::once(encoder.finish()));
1862    }
1863
1864    // draw_rect, draw_sdf, draw_quad, draw_shadow, draw_path_quad removed —
1865    // replaced by batched rendering in render().
1866
1867    #[allow(clippy::too_many_arguments)]
1868    fn draw_image(
1869        &self,
1870        pass: &mut wgpu::RenderPass,
1871        image: &teksilo_canvas::ImageQuad,
1872        scale_factor: f32,
1873        viewport_width: u32,
1874        viewport_height: u32,
1875        opacity: f32,
1876        transform: &Transform2D,
1877        index_binding: Option<(&wgpu::Buffer, u64, u64)>,
1878    ) {
1879        let bind_group = match self.image_manager.get_bind_group(&image.name) {
1880            Some(bg) => bg,
1881            None => return,
1882        };
1883
1884        let [x, y, w, h] = image.screen;
1885        let sx = x * scale_factor;
1886        let sy = y * scale_factor;
1887        let sw = w * scale_factor;
1888        let sh = h * scale_factor;
1889
1890        // Tintable mode: image is an alpha mask tinted with the given color (flag=0).
1891        // Full-color mode: image RGB used directly (flag=1, existing behavior).
1892        let (color, flags) = if let Some(tint) = image.tint {
1893            // Tint colors are sRGB-encoded (from teksilo_tokens::Color) — linearize
1894            // for the Rgba8UnormSrgb surface, same as all other vertex colors.
1895            (
1896                crate::vertex::srgb_to_linear_rgba([tint[0], tint[1], tint[2], tint[3] * opacity]),
1897                0,
1898            )
1899        } else {
1900            (
1901                [1.0, 1.0, 1.0, opacity],
1902                crate::vertex::QUAD_FLAG_COLOR_GLYPH,
1903            )
1904        };
1905
1906        let verts = [
1907            QuadVertex {
1908                position: [sx, sy],
1909                tex_coord: [0.0, 0.0],
1910                color,
1911                flags,
1912                _pad: 0,
1913            },
1914            QuadVertex {
1915                position: [sx + sw, sy],
1916                tex_coord: [1.0, 0.0],
1917                color,
1918                flags,
1919                _pad: 0,
1920            },
1921            QuadVertex {
1922                position: [sx + sw, sy + sh],
1923                tex_coord: [1.0, 1.0],
1924                color,
1925                flags,
1926                _pad: 0,
1927            },
1928            QuadVertex {
1929                position: [sx, sy + sh],
1930                tex_coord: [0.0, 1.0],
1931                color,
1932                flags,
1933                _pad: 0,
1934            },
1935        ];
1936
1937        let ndc_verts: [QuadVertex; 4] = std::array::from_fn(|i| {
1938            let v = verts[i];
1939            let tp = apply_transform_pixel(v.position, transform);
1940            QuadVertex {
1941                position: pixel_to_ndc(tp, viewport_width, viewport_height),
1942                ..v
1943            }
1944        });
1945
1946        // Reuse the persistent quad stream buffer instead of allocating
1947        // a fresh vertex buffer per image. Indices come from the shared
1948        // index stream populated at the top of `render()`.
1949        let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1950        let Some((vb, v_off, v_len)) = self.streams.quad.write(&self.queue, bytes) else {
1951            return;
1952        };
1953        let Some((ib, _, _)) = index_binding else {
1954            return;
1955        };
1956
1957        pass.set_pipeline(&self.quad_pipeline);
1958        pass.set_bind_group(0, bind_group, &[]);
1959        pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
1960        pass.set_index_buffer(ib.slice(0..24), wgpu::IndexFormat::Uint32);
1961        pass.draw_indexed(0..6, 0, 0..1);
1962    }
1963
1964    /// Upload path atlas texture data.
1965    fn upload_path_atlas(&mut self, width: u32, height: u32, pixels: Vec<u8>) {
1966        if width == 0 || height == 0 {
1967            return;
1968        }
1969
1970        let needs_recreate = self
1971            .path_atlas_texture
1972            .as_ref()
1973            .is_none_or(|t| t.width != width || t.height != height);
1974
1975        if needs_recreate {
1976            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1977                label: Some("path_atlas"),
1978                size: wgpu::Extent3d {
1979                    width,
1980                    height,
1981                    depth_or_array_layers: 1,
1982                },
1983                mip_level_count: 1,
1984                sample_count: 1,
1985                dimension: wgpu::TextureDimension::D2,
1986                format: wgpu::TextureFormat::Rgba8UnormSrgb,
1987                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1988                view_formats: &[],
1989            });
1990
1991            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1992            let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
1993                mag_filter: wgpu::FilterMode::Linear,
1994                min_filter: wgpu::FilterMode::Linear,
1995                ..Default::default()
1996            });
1997
1998            let bind_group_layout = self.quad_pipeline.get_bind_group_layout(0);
1999            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2000                label: Some("path_atlas_bind_group"),
2001                layout: &bind_group_layout,
2002                entries: &[
2003                    wgpu::BindGroupEntry {
2004                        binding: 0,
2005                        resource: wgpu::BindingResource::TextureView(&view),
2006                    },
2007                    wgpu::BindGroupEntry {
2008                        binding: 1,
2009                        resource: wgpu::BindingResource::Sampler(&sampler),
2010                    },
2011                ],
2012            });
2013
2014            self.path_atlas_texture = Some(AtlasTexture {
2015                texture,
2016                bind_group,
2017                width,
2018                height,
2019            });
2020        }
2021
2022        if let Some(atlas) = &self.path_atlas_texture {
2023            self.queue.write_texture(
2024                wgpu::TexelCopyTextureInfo {
2025                    texture: &atlas.texture,
2026                    mip_level: 0,
2027                    origin: wgpu::Origin3d::ZERO,
2028                    aspect: wgpu::TextureAspect::All,
2029                },
2030                &pixels,
2031                wgpu::TexelCopyBufferLayout {
2032                    offset: 0,
2033                    bytes_per_row: Some(width * 4),
2034                    rows_per_image: Some(height),
2035                },
2036                wgpu::Extent3d {
2037                    width,
2038                    height,
2039                    depth_or_array_layers: 1,
2040                },
2041            );
2042        }
2043    }
2044
2045    pub fn device(&self) -> &wgpu::Device {
2046        &self.device
2047    }
2048
2049    pub fn queue(&self) -> &wgpu::Queue {
2050        &self.queue
2051    }
2052
2053    /// Register an image for rendering by name.
2054    pub fn register_image(&mut self, name: &str, width: u32, height: u32, pixels: &[u8]) {
2055        let layout = self.quad_pipeline.get_bind_group_layout(0);
2056        self.image_manager.register_image(
2057            name,
2058            width,
2059            height,
2060            pixels,
2061            &self.device,
2062            &self.queue,
2063            &layout,
2064        );
2065    }
2066
2067    /// Remove a registered image.
2068    pub fn remove_image(&mut self, name: &str) {
2069        self.image_manager.remove(name);
2070    }
2071}
2072
2073/// Build 4 QuadVertex for a path entry (in pixel space, pre-NDC).
2074fn path_quad_verts(
2075    entry: &teksilo_canvas::PathEntry,
2076    placement: &crate::path_atlas::PathPlacement,
2077    atlas_width: u32,
2078    atlas_height: u32,
2079    opacity: f32,
2080    transform: &Transform2D,
2081) -> [QuadVertex; 4] {
2082    // The rect comes from the placement, never recomputed from
2083    // `entry.bounds` — the atlas baked its bitmap against this exact rect,
2084    // and a second derivation of it is how the two drifted apart before
2085    // (see `PathPlacement`).
2086    let region = &placement.region;
2087    let [sx, sy, sw, sh] = placement.device_rect;
2088
2089    let aw = atlas_width.max(1) as f32;
2090    let ah = atlas_height.max(1) as f32;
2091    let u0 = region.x as f32 / aw;
2092    let v0 = region.y as f32 / ah;
2093    let u1 = (region.x + region.w) as f32 / aw;
2094    let v1 = (region.y + region.h) as f32 / ah;
2095
2096    // The path atlas stores coverage in its alpha channel; the monochrome
2097    // quad path (`flags = 0`) tints with the vertex RGB and multiplies by
2098    // that coverage. The `Rgba8UnormSrgb` target expects linear RGB from the
2099    // shader, so linearize `entry.color` here exactly like every other
2100    // pipeline (rect / sdf / shadow / image) — otherwise paths render with a
2101    // gamma error against everything else.
2102    let lin = crate::vertex::srgb_to_linear_rgba(entry.color);
2103    let color = [lin[0], lin[1], lin[2], entry.color[3] * opacity];
2104
2105    let positions = [
2106        apply_transform_pixel([sx, sy], transform),
2107        apply_transform_pixel([sx + sw, sy], transform),
2108        apply_transform_pixel([sx + sw, sy + sh], transform),
2109        apply_transform_pixel([sx, sy + sh], transform),
2110    ];
2111    let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
2112
2113    // The shader outputs `vertex.rgb * tex.a` for `flags = 0`, equivalent to
2114    // `linear_path_color * path_coverage`.
2115    [
2116        QuadVertex {
2117            position: positions[0],
2118            tex_coord: uvs[0],
2119            color,
2120            flags: 0,
2121            _pad: 0,
2122        },
2123        QuadVertex {
2124            position: positions[1],
2125            tex_coord: uvs[1],
2126            color,
2127            flags: 0,
2128            _pad: 0,
2129        },
2130        QuadVertex {
2131            position: positions[2],
2132            tex_coord: uvs[2],
2133            color,
2134            flags: 0,
2135            _pad: 0,
2136        },
2137        QuadVertex {
2138            position: positions[3],
2139            tex_coord: uvs[3],
2140            color,
2141            flags: 0,
2142            _pad: 0,
2143        },
2144    ]
2145}
2146
2147/// Build 4 [`PathGradientVertex`](crate::vertex::PathGradientVertex)es for a
2148/// gradient-filled path entry (in pixel space, pre-NDC). Same
2149/// bounds/atlas-UV/position math as [`path_quad_verts`] (the solid-path
2150/// counterpart) — the actual encoding lives on
2151/// `PathGradientVertex::from_path_entry` (mirrors the shared
2152/// `encode_paint_data`/`encode_stops` helpers used by [`SdfVertex`]); this
2153/// wrapper exists so the call site in `render()` reads symmetrically with
2154/// `path_quad_verts`.
2155fn path_gradient_quad_verts(
2156    entry: &teksilo_canvas::PathEntry,
2157    placement: &crate::path_atlas::PathPlacement,
2158    scale_factor: f32,
2159    atlas_width: u32,
2160    atlas_height: u32,
2161    current_opacity: f32,
2162    transform: &Transform2D,
2163) -> [crate::vertex::PathGradientVertex; 4] {
2164    crate::vertex::PathGradientVertex::from_path_entry(
2165        entry,
2166        placement,
2167        scale_factor,
2168        atlas_width,
2169        atlas_height,
2170        current_opacity,
2171        transform,
2172    )
2173}
2174
2175fn pixel_to_ndc(pixel: [f32; 2], viewport_width: u32, viewport_height: u32) -> [f32; 2] {
2176    let x = (pixel[0] / viewport_width as f32) * 2.0 - 1.0;
2177    let y = 1.0 - (pixel[1] / viewport_height as f32) * 2.0; // flip Y
2178    [x, y]
2179}
2180
2181/// Apply a 2D affine transform to pixel coordinates.
2182fn apply_transform_pixel(pixel: [f32; 2], transform: &Transform2D) -> [f32; 2] {
2183    let [a, b, c, d, tx, ty] = transform.m;
2184    [
2185        a * pixel[0] + c * pixel[1] + tx,
2186        b * pixel[0] + d * pixel[1] + ty,
2187    ]
2188}
2189
2190/// Result of running the dual-Kawase chain on a `BlurScope`'s
2191/// intermediate. The returned texture is the final upsampled level —
2192/// it shares the same bucket-size convention as the input (only
2193/// `(used_w, used_h)` of `(bucket_w, bucket_h)` holds rendered
2194/// content), so the caller maps UVs as `used / bucket`.
2195struct KawaseResult {
2196    texture: crate::blur::AcquiredTexture,
2197    used_w: u32,
2198    used_h: u32,
2199    bucket_w: u32,
2200    bucket_h: u32,
2201}
2202
2203/// Run a dual-Kawase blur chain on `source`. The chain depth is chosen
2204/// from the requested radius; each pass halves (downsample) or doubles
2205/// (upsample) the active region's size. Returns the final upsampled
2206/// texture handle (which may be the input handle itself if the chain
2207/// is a single round-trip).
2208#[allow(clippy::too_many_arguments)]
2209fn run_kawase_chain(
2210    device: &wgpu::Device,
2211    queue: &wgpu::Queue,
2212    encoder: &mut wgpu::CommandEncoder,
2213    pool: &mut crate::blur::BlurPool,
2214    pipelines: &crate::blur::BlurPipelines,
2215    source: crate::blur::AcquiredTexture,
2216    used_w: u32,
2217    used_h: u32,
2218    bucket_w: u32,
2219    bucket_h: u32,
2220    radius_device_px: f32,
2221) -> KawaseResult {
2222    let levels = crate::blur::kawase_levels(radius_device_px);
2223
2224    // Track the chain as (handle, used_w, used_h, bucket_w, bucket_h).
2225    // Each downsample halves used_w/h; the bucket size we sample from
2226    // is the *previous* level's bucket.
2227    let mut current = (source, used_w, used_h, bucket_w, bucket_h);
2228
2229    // Upsample needs to know all intermediate bucket sizes so we can
2230    // walk back up. Stash one entry per chain level (input + each
2231    // downsample target).
2232    let mut chain: Vec<(crate::blur::AcquiredTexture, u32, u32, u32, u32)> =
2233        Vec::with_capacity(levels as usize + 1);
2234    chain.push(current);
2235
2236    // Per-pass kernel offset multiplier. Bjørge's reference uses 0.5
2237    // for both passes; the actual blur radius this produces is
2238    // proportional to `2^levels * 0.5`, which roughly matches the
2239    // requested Gaussian-equivalent radius for typical UI values.
2240    const KERNEL_OFFSET: f32 = 0.5;
2241
2242    // Downsample chain: source → mip1 → mip2 → ...
2243    for _ in 0..levels {
2244        let (src_handle, src_used_w, src_used_h, src_bucket_w, src_bucket_h) = current;
2245        let dst_used_w = (src_used_w / 2).max(1);
2246        let dst_used_h = (src_used_h / 2).max(1);
2247        let dst = pool.acquire(device, dst_used_w, dst_used_h);
2248        let (dst_bucket_w, dst_bucket_h) = pool.dimensions(dst);
2249
2250        // Build per-pass uniforms: source-bucket UV-offset.
2251        let params = crate::blur::BlurParams {
2252            offset: crate::blur::kawase_offset(src_bucket_w, src_bucket_h, KERNEL_OFFSET),
2253        };
2254        queue.write_buffer(&pipelines.params_buffer, 0, bytemuck::bytes_of(&params));
2255        let bind_group = pool.make_bind_group(device, src_handle, &pipelines.params_buffer);
2256
2257        run_kawase_pass(
2258            encoder,
2259            &pipelines.down,
2260            &bind_group,
2261            pool.view(dst),
2262            dst_used_w,
2263            dst_used_h,
2264            "kawase_down_pass",
2265        );
2266
2267        current = (dst, dst_used_w, dst_used_h, dst_bucket_w, dst_bucket_h);
2268        chain.push(current);
2269    }
2270
2271    // Upsample chain: mipN → mipN-1 → ... → mip0 (a fresh allocation;
2272    // we don't write back into the source texture because some Kawase
2273    // implementations rely on the source bucket's content surviving).
2274    for level in (0..levels).rev() {
2275        let (src_handle, _src_used_w, _src_used_h, src_bucket_w, src_bucket_h) = current;
2276        let target = chain[level as usize];
2277        let dst_used_w = target.1;
2278        let dst_used_h = target.2;
2279        let dst = pool.acquire(device, dst_used_w, dst_used_h);
2280        let (dst_bucket_w, dst_bucket_h) = pool.dimensions(dst);
2281
2282        let params = crate::blur::BlurParams {
2283            offset: crate::blur::kawase_offset(src_bucket_w, src_bucket_h, KERNEL_OFFSET),
2284        };
2285        queue.write_buffer(&pipelines.params_buffer, 0, bytemuck::bytes_of(&params));
2286        let bind_group = pool.make_bind_group(device, src_handle, &pipelines.params_buffer);
2287
2288        run_kawase_pass(
2289            encoder,
2290            &pipelines.up,
2291            &bind_group,
2292            pool.view(dst),
2293            dst_used_w,
2294            dst_used_h,
2295            "kawase_up_pass",
2296        );
2297
2298        current = (dst, dst_used_w, dst_used_h, dst_bucket_w, dst_bucket_h);
2299    }
2300
2301    KawaseResult {
2302        texture: current.0,
2303        used_w: current.1,
2304        used_h: current.2,
2305        bucket_w: current.3,
2306        bucket_h: current.4,
2307    }
2308}
2309
2310/// Run one full-screen-triangle Kawase pass. The viewport is set to
2311/// `(used_w, used_h)` — the destination bucket may be larger but we
2312/// only write the upper-left sub-rect that the next pass will sample
2313/// from.
2314fn run_kawase_pass(
2315    encoder: &mut wgpu::CommandEncoder,
2316    pipeline: &wgpu::RenderPipeline,
2317    bind_group: &wgpu::BindGroup,
2318    target_view: &wgpu::TextureView,
2319    used_w: u32,
2320    used_h: u32,
2321    label: &str,
2322) {
2323    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2324        label: Some(label),
2325        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2326            view: target_view,
2327            resolve_target: None,
2328            ops: wgpu::Operations {
2329                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2330                store: wgpu::StoreOp::Store,
2331            },
2332            depth_slice: None,
2333        })],
2334        depth_stencil_attachment: None,
2335        timestamp_writes: None,
2336        occlusion_query_set: None,
2337        multiview_mask: None,
2338    });
2339    pass.set_pipeline(pipeline);
2340    pass.set_bind_group(0, bind_group, &[]);
2341    // Full-screen triangle covers the whole viewport — restricting the
2342    // viewport to the used sub-rect keeps the over-allocated bucket
2343    // clean and (more importantly) limits the fragment work.
2344    pass.set_viewport(0.0, 0.0, used_w as f32, used_h as f32, 0.0, 1.0);
2345    pass.draw(0..3, 0..1);
2346}
2347
2348/// Composite the final blurred intermediate onto the parent target as
2349/// a textured quad at `bounds` (logical pixels). Uses the same quad
2350/// pipeline as static images: builds 4 vertices in NDC with image
2351/// flag set, binds the intermediate texture + sampler, and issues one
2352/// indexed draw.
2353///
2354/// `index_binding` is the per-frame index buffer (the first 6 u32s
2355/// already encode the standard quad index pattern, so we slice 24
2356/// bytes off the front).
2357#[allow(clippy::too_many_arguments)]
2358fn composite_blur_quad(
2359    device: &wgpu::Device,
2360    queue: &wgpu::Queue,
2361    pass: &mut wgpu::RenderPass<'_>,
2362    pool: &crate::blur::BlurPool,
2363    quad_pipeline: &wgpu::RenderPipeline,
2364    quad_bind_group_layout: &wgpu::BindGroupLayout,
2365    sampler: &wgpu::Sampler,
2366    quad_stream: &crate::stream_buffer::StreamBuffer,
2367    index_binding: Option<(&wgpu::Buffer, u64, u64)>,
2368    blurred: crate::blur::AcquiredTexture,
2369    used_w: u32,
2370    used_h: u32,
2371    bucket_w: u32,
2372    bucket_h: u32,
2373    bounds: teksilo_canvas::Rect,
2374    scale_factor: f32,
2375    viewport_width: u32,
2376    viewport_height: u32,
2377) {
2378    let view = pool.view(blurred);
2379    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2380        label: Some("blur_composite_bind_group"),
2381        layout: quad_bind_group_layout,
2382        entries: &[
2383            wgpu::BindGroupEntry {
2384                binding: 0,
2385                resource: wgpu::BindingResource::TextureView(view),
2386            },
2387            wgpu::BindGroupEntry {
2388                binding: 1,
2389                resource: wgpu::BindingResource::Sampler(sampler),
2390            },
2391        ],
2392    });
2393
2394    // Vertex positions in device pixels, converted to NDC.
2395    let sx = bounds.x * scale_factor;
2396    let sy = bounds.y * scale_factor;
2397    let sw = bounds.width * scale_factor;
2398    let sh = bounds.height * scale_factor;
2399
2400    // UVs map the used sub-rect inside the bucket. The bucket's
2401    // upper-left holds the rendered content; the rest is the
2402    // cleared-to-transparent padding from the bucket's allocation.
2403    let u_max = used_w as f32 / bucket_w as f32;
2404    let v_max = used_h as f32 / bucket_h as f32;
2405
2406    // Image flag (bit 0 = 1 → fragment shader uses tex.rgb directly).
2407    let flags = 1u32;
2408    let color = [1.0, 1.0, 1.0, 1.0];
2409
2410    let p_tl = pixel_to_ndc([sx, sy], viewport_width, viewport_height);
2411    let p_tr = pixel_to_ndc([sx + sw, sy], viewport_width, viewport_height);
2412    let p_br = pixel_to_ndc([sx + sw, sy + sh], viewport_width, viewport_height);
2413    let p_bl = pixel_to_ndc([sx, sy + sh], viewport_width, viewport_height);
2414
2415    let verts: [QuadVertex; 4] = [
2416        QuadVertex {
2417            position: p_tl,
2418            tex_coord: [0.0, 0.0],
2419            color,
2420            flags,
2421            _pad: 0,
2422        },
2423        QuadVertex {
2424            position: p_tr,
2425            tex_coord: [u_max, 0.0],
2426            color,
2427            flags,
2428            _pad: 0,
2429        },
2430        QuadVertex {
2431            position: p_br,
2432            tex_coord: [u_max, v_max],
2433            color,
2434            flags,
2435            _pad: 0,
2436        },
2437        QuadVertex {
2438            position: p_bl,
2439            tex_coord: [0.0, v_max],
2440            color,
2441            flags,
2442            _pad: 0,
2443        },
2444    ];
2445
2446    // Caller has already sized `quad_stream` for the worst-case quad
2447    // count *including composites* (see render()'s up-front sizing).
2448    // The index buffer's first 6 u32s = `[0, 1, 2, 0, 2, 3]` (the
2449    // standard quad pattern), reused here.
2450    let _ = device; // device is only used for bind-group creation above
2451    let Some((vb, v_off, v_len)) = quad_stream.write(queue, bytemuck::cast_slice(&verts)) else {
2452        return;
2453    };
2454    let Some((ib, _, _)) = index_binding else {
2455        return;
2456    };
2457    let composite_index_bytes: u64 = 6 * std::mem::size_of::<u32>() as u64;
2458
2459    pass.set_pipeline(quad_pipeline);
2460    pass.set_bind_group(0, &bind_group, &[]);
2461    pass.set_viewport(
2462        0.0,
2463        0.0,
2464        viewport_width as f32,
2465        viewport_height as f32,
2466        0.0,
2467        1.0,
2468    );
2469    pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
2470    pass.set_index_buffer(
2471        ib.slice(0..composite_index_bytes),
2472        wgpu::IndexFormat::Uint32,
2473    );
2474    pass.draw_indexed(0..6, 0, 0..1);
2475}
2476
2477// --- Pipeline creation ---
2478
2479fn create_rect_pipeline(
2480    device: &wgpu::Device,
2481    format: wgpu::TextureFormat,
2482) -> wgpu::RenderPipeline {
2483    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2484        label: Some("rect_shader"),
2485        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/rect.wgsl").into()),
2486    });
2487
2488    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2489        label: Some("rect_pipeline_layout"),
2490        bind_group_layouts: &[],
2491        immediate_size: 0,
2492    });
2493
2494    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2495        label: Some("rect_pipeline"),
2496        layout: Some(&layout),
2497        vertex: wgpu::VertexState {
2498            module: &shader,
2499            entry_point: Some("vs_main"),
2500            buffers: &[Some(wgpu::VertexBufferLayout {
2501                array_stride: std::mem::size_of::<RectVertex>() as u64,
2502                step_mode: wgpu::VertexStepMode::Vertex,
2503                attributes: &[
2504                    wgpu::VertexAttribute {
2505                        offset: 0,
2506                        shader_location: 0,
2507                        format: wgpu::VertexFormat::Float32x2,
2508                    },
2509                    wgpu::VertexAttribute {
2510                        offset: 8,
2511                        shader_location: 1,
2512                        format: wgpu::VertexFormat::Float32x4,
2513                    },
2514                ],
2515            })],
2516            compilation_options: Default::default(),
2517        },
2518        fragment: Some(wgpu::FragmentState {
2519            module: &shader,
2520            entry_point: Some("fs_main"),
2521            targets: &[Some(wgpu::ColorTargetState {
2522                format,
2523                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2524                write_mask: wgpu::ColorWrites::ALL,
2525            })],
2526            compilation_options: Default::default(),
2527        }),
2528        primitive: wgpu::PrimitiveState {
2529            topology: wgpu::PrimitiveTopology::TriangleList,
2530            ..Default::default()
2531        },
2532        depth_stencil: None,
2533        multisample: wgpu::MultisampleState::default(),
2534        multiview_mask: None,
2535        cache: None,
2536    })
2537}
2538
2539fn create_sdf_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> wgpu::RenderPipeline {
2540    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2541        label: Some("sdf_shader"),
2542        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/sdf.wgsl").into()),
2543    });
2544
2545    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2546        label: Some("sdf_pipeline_layout"),
2547        bind_group_layouts: &[],
2548        immediate_size: 0,
2549    });
2550
2551    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2552        label: Some("sdf_pipeline"),
2553        layout: Some(&layout),
2554        vertex: wgpu::VertexState {
2555            module: &shader,
2556            entry_point: Some("vs_main"),
2557            buffers: &[Some(wgpu::VertexBufferLayout {
2558                array_stride: std::mem::size_of::<SdfVertex>() as u64,
2559                step_mode: wgpu::VertexStepMode::Vertex,
2560                attributes: &[
2561                    wgpu::VertexAttribute {
2562                        offset: 0,
2563                        shader_location: 0,
2564                        format: wgpu::VertexFormat::Float32x2, // position
2565                    },
2566                    wgpu::VertexAttribute {
2567                        offset: 8,
2568                        shader_location: 1,
2569                        format: wgpu::VertexFormat::Float32x2, // local_uv
2570                    },
2571                    wgpu::VertexAttribute {
2572                        offset: 16,
2573                        shader_location: 2,
2574                        format: wgpu::VertexFormat::Float32x4, // color
2575                    },
2576                    wgpu::VertexAttribute {
2577                        offset: 32,
2578                        shader_location: 3,
2579                        format: wgpu::VertexFormat::Float32x4, // corner_radii
2580                    },
2581                    wgpu::VertexAttribute {
2582                        offset: 48,
2583                        shader_location: 4,
2584                        format: wgpu::VertexFormat::Float32x4, // shape_params
2585                    },
2586                    wgpu::VertexAttribute {
2587                        offset: 64,
2588                        shader_location: 5,
2589                        format: wgpu::VertexFormat::Float32x4, // gradient_geo
2590                    },
2591                    wgpu::VertexAttribute {
2592                        offset: 80,
2593                        shader_location: 6,
2594                        format: wgpu::VertexFormat::Float32x4, // gradient_color0
2595                    },
2596                    wgpu::VertexAttribute {
2597                        offset: 96,
2598                        shader_location: 7,
2599                        format: wgpu::VertexFormat::Float32x4, // gradient_color1
2600                    },
2601                    wgpu::VertexAttribute {
2602                        offset: 112,
2603                        shader_location: 8,
2604                        format: wgpu::VertexFormat::Float32x4, // gradient_color2
2605                    },
2606                    wgpu::VertexAttribute {
2607                        offset: 128,
2608                        shader_location: 9,
2609                        format: wgpu::VertexFormat::Float32x4, // gradient_color3
2610                    },
2611                    wgpu::VertexAttribute {
2612                        offset: 144,
2613                        shader_location: 10,
2614                        format: wgpu::VertexFormat::Float32x4, // gradient_offsets
2615                    },
2616                ],
2617            })],
2618            compilation_options: Default::default(),
2619        },
2620        fragment: Some(wgpu::FragmentState {
2621            module: &shader,
2622            entry_point: Some("fs_main"),
2623            targets: &[Some(wgpu::ColorTargetState {
2624                format,
2625                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2626                write_mask: wgpu::ColorWrites::ALL,
2627            })],
2628            compilation_options: Default::default(),
2629        }),
2630        primitive: wgpu::PrimitiveState {
2631            topology: wgpu::PrimitiveTopology::TriangleList,
2632            ..Default::default()
2633        },
2634        depth_stencil: None,
2635        multisample: wgpu::MultisampleState::default(),
2636        multiview_mask: None,
2637        cache: None,
2638    })
2639}
2640
2641fn create_quad_pipeline(
2642    device: &wgpu::Device,
2643    format: wgpu::TextureFormat,
2644) -> wgpu::RenderPipeline {
2645    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2646        label: Some("quad_shader"),
2647        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/quad.wgsl").into()),
2648    });
2649
2650    let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2651        label: Some("quad_bind_group_layout"),
2652        entries: &[
2653            wgpu::BindGroupLayoutEntry {
2654                binding: 0,
2655                visibility: wgpu::ShaderStages::FRAGMENT,
2656                ty: wgpu::BindingType::Texture {
2657                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
2658                    view_dimension: wgpu::TextureViewDimension::D2,
2659                    multisampled: false,
2660                },
2661                count: None,
2662            },
2663            wgpu::BindGroupLayoutEntry {
2664                binding: 1,
2665                visibility: wgpu::ShaderStages::FRAGMENT,
2666                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2667                count: None,
2668            },
2669        ],
2670    });
2671
2672    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2673        label: Some("quad_pipeline_layout"),
2674        bind_group_layouts: &[Some(&bind_group_layout)],
2675        immediate_size: 0,
2676    });
2677
2678    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2679        label: Some("quad_pipeline"),
2680        layout: Some(&layout),
2681        vertex: wgpu::VertexState {
2682            module: &shader,
2683            entry_point: Some("vs_main"),
2684            buffers: &[Some(wgpu::VertexBufferLayout {
2685                array_stride: std::mem::size_of::<QuadVertex>() as u64,
2686                step_mode: wgpu::VertexStepMode::Vertex,
2687                attributes: &[
2688                    wgpu::VertexAttribute {
2689                        offset: 0,
2690                        shader_location: 0,
2691                        format: wgpu::VertexFormat::Float32x2, // position
2692                    },
2693                    wgpu::VertexAttribute {
2694                        offset: 8,
2695                        shader_location: 1,
2696                        format: wgpu::VertexFormat::Float32x2, // tex_coord
2697                    },
2698                    wgpu::VertexAttribute {
2699                        offset: 16,
2700                        shader_location: 2,
2701                        format: wgpu::VertexFormat::Float32x4, // color
2702                    },
2703                    wgpu::VertexAttribute {
2704                        offset: 32,
2705                        shader_location: 3,
2706                        format: wgpu::VertexFormat::Uint32, // flags (bit 0 = color glyph)
2707                    },
2708                ],
2709            })],
2710            compilation_options: Default::default(),
2711        },
2712        fragment: Some(wgpu::FragmentState {
2713            module: &shader,
2714            entry_point: Some("fs_main"),
2715            targets: &[Some(wgpu::ColorTargetState {
2716                format,
2717                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2718                write_mask: wgpu::ColorWrites::ALL,
2719            })],
2720            compilation_options: Default::default(),
2721        }),
2722        primitive: wgpu::PrimitiveState {
2723            topology: wgpu::PrimitiveTopology::TriangleList,
2724            ..Default::default()
2725        },
2726        depth_stencil: None,
2727        multisample: wgpu::MultisampleState::default(),
2728        multiview_mask: None,
2729        cache: None,
2730    })
2731}
2732
2733/// Build the gradient-filled path pipeline (Tier 3, gradient paint
2734/// only). Reuses `texture_bind_group_layout` — the SAME group(0) layout
2735/// the `quad_pipeline` exposes (texture + sampler) — as its own group 0,
2736/// so the path atlas's bind group (built once, shared with the solid
2737/// path quad batch) binds unchanged for both pipelines.
2738fn create_path_gradient_pipeline(
2739    device: &wgpu::Device,
2740    format: wgpu::TextureFormat,
2741    texture_bind_group_layout: &wgpu::BindGroupLayout,
2742) -> wgpu::RenderPipeline {
2743    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2744        label: Some("path_gradient_shader"),
2745        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/path_gradient.wgsl").into()),
2746    });
2747
2748    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2749        label: Some("path_gradient_pipeline_layout"),
2750        bind_group_layouts: &[Some(texture_bind_group_layout)],
2751        immediate_size: 0,
2752    });
2753
2754    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2755        label: Some("path_gradient_pipeline"),
2756        layout: Some(&layout),
2757        vertex: wgpu::VertexState {
2758            module: &shader,
2759            entry_point: Some("vs_main"),
2760            buffers: &[Some(wgpu::VertexBufferLayout {
2761                array_stride: std::mem::size_of::<crate::vertex::PathGradientVertex>() as u64,
2762                step_mode: wgpu::VertexStepMode::Vertex,
2763                attributes: &[
2764                    wgpu::VertexAttribute {
2765                        offset: 0,
2766                        shader_location: 0,
2767                        format: wgpu::VertexFormat::Float32x2, // position
2768                    },
2769                    wgpu::VertexAttribute {
2770                        offset: 8,
2771                        shader_location: 1,
2772                        format: wgpu::VertexFormat::Float32x2, // tex_coord
2773                    },
2774                    wgpu::VertexAttribute {
2775                        offset: 16,
2776                        shader_location: 2,
2777                        format: wgpu::VertexFormat::Float32x2, // local_uv
2778                    },
2779                    wgpu::VertexAttribute {
2780                        offset: 24,
2781                        shader_location: 3,
2782                        format: wgpu::VertexFormat::Uint32, // paint_type
2783                    },
2784                    // Offset 28 (_pad: u32) is skipped — no attribute.
2785                    wgpu::VertexAttribute {
2786                        offset: 32,
2787                        shader_location: 4,
2788                        format: wgpu::VertexFormat::Float32x4, // gradient_geo
2789                    },
2790                    wgpu::VertexAttribute {
2791                        offset: 48,
2792                        shader_location: 5,
2793                        format: wgpu::VertexFormat::Float32x4, // gradient_color0
2794                    },
2795                    wgpu::VertexAttribute {
2796                        offset: 64,
2797                        shader_location: 6,
2798                        format: wgpu::VertexFormat::Float32x4, // gradient_color1
2799                    },
2800                    wgpu::VertexAttribute {
2801                        offset: 80,
2802                        shader_location: 7,
2803                        format: wgpu::VertexFormat::Float32x4, // gradient_color2
2804                    },
2805                    wgpu::VertexAttribute {
2806                        offset: 96,
2807                        shader_location: 8,
2808                        format: wgpu::VertexFormat::Float32x4, // gradient_color3
2809                    },
2810                    wgpu::VertexAttribute {
2811                        offset: 112,
2812                        shader_location: 9,
2813                        format: wgpu::VertexFormat::Float32x4, // gradient_offsets
2814                    },
2815                ],
2816            })],
2817            compilation_options: Default::default(),
2818        },
2819        fragment: Some(wgpu::FragmentState {
2820            module: &shader,
2821            entry_point: Some("fs_main"),
2822            targets: &[Some(wgpu::ColorTargetState {
2823                format,
2824                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2825                write_mask: wgpu::ColorWrites::ALL,
2826            })],
2827            compilation_options: Default::default(),
2828        }),
2829        primitive: wgpu::PrimitiveState {
2830            topology: wgpu::PrimitiveTopology::TriangleList,
2831            ..Default::default()
2832        },
2833        depth_stencil: None,
2834        multisample: wgpu::MultisampleState::default(),
2835        multiview_mask: None,
2836        cache: None,
2837    })
2838}
2839
2840fn create_shadow_pipeline(
2841    device: &wgpu::Device,
2842    format: wgpu::TextureFormat,
2843) -> wgpu::RenderPipeline {
2844    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2845        label: Some("shadow_shader"),
2846        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/shadow.wgsl").into()),
2847    });
2848
2849    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2850        label: Some("shadow_pipeline_layout"),
2851        bind_group_layouts: &[],
2852        immediate_size: 0,
2853    });
2854
2855    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2856        label: Some("shadow_pipeline"),
2857        layout: Some(&layout),
2858        vertex: wgpu::VertexState {
2859            module: &shader,
2860            entry_point: Some("vs_main"),
2861            buffers: &[Some(wgpu::VertexBufferLayout {
2862                array_stride: std::mem::size_of::<ShadowVertex>() as u64,
2863                step_mode: wgpu::VertexStepMode::Vertex,
2864                attributes: &[
2865                    wgpu::VertexAttribute {
2866                        offset: 0,
2867                        shader_location: 0,
2868                        format: wgpu::VertexFormat::Float32x2, // position
2869                    },
2870                    wgpu::VertexAttribute {
2871                        offset: 8,
2872                        shader_location: 1,
2873                        format: wgpu::VertexFormat::Float32x2, // local_uv
2874                    },
2875                    wgpu::VertexAttribute {
2876                        offset: 16,
2877                        shader_location: 2,
2878                        format: wgpu::VertexFormat::Float32x4, // shadow_color
2879                    },
2880                    wgpu::VertexAttribute {
2881                        offset: 32,
2882                        shader_location: 3,
2883                        format: wgpu::VertexFormat::Float32x4, // corner_radii
2884                    },
2885                    wgpu::VertexAttribute {
2886                        offset: 48,
2887                        shader_location: 4,
2888                        format: wgpu::VertexFormat::Float32x4, // shadow_params
2889                    },
2890                    wgpu::VertexAttribute {
2891                        offset: 64,
2892                        shader_location: 5,
2893                        format: wgpu::VertexFormat::Float32x4, // shape_offset
2894                    },
2895                ],
2896            })],
2897            compilation_options: Default::default(),
2898        },
2899        fragment: Some(wgpu::FragmentState {
2900            module: &shader,
2901            entry_point: Some("fs_main"),
2902            targets: &[Some(wgpu::ColorTargetState {
2903                format,
2904                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2905                write_mask: wgpu::ColorWrites::ALL,
2906            })],
2907            compilation_options: Default::default(),
2908        }),
2909        primitive: wgpu::PrimitiveState {
2910            topology: wgpu::PrimitiveTopology::TriangleList,
2911            ..Default::default()
2912        },
2913        depth_stencil: None,
2914        multisample: wgpu::MultisampleState::default(),
2915        multiview_mask: None,
2916        cache: None,
2917    })
2918}
2919
2920/// Per-pipeline quad counts for one frame's stream-buffer sizing.
2921///
2922/// Upper bound per pipeline = `quads * 4 vertices` because every
2923/// drawable produces exactly 4 vertices. Every count here must match
2924/// what the draw walk actually writes into the corresponding
2925/// [`StreamBuffer`](crate::stream_buffer::StreamBuffer) — an
2926/// undercount overflows the buffer at write time (debug assert +
2927/// dropped draws; see `StreamBuffer::write`). Kept as a pure function
2928/// of the frame so the accounting is unit-testable headlessly (the
2929/// GPU path has no headless coverage).
2930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2931pub(crate) struct StreamQuadCounts {
2932    pub rect: usize,
2933    pub sdf: usize,
2934    pub quad: usize,
2935    pub shadow: usize,
2936    pub anim_proc: usize,
2937    /// Gradient-filled path quads (Tier 3, `path_gradient_pipeline`).
2938    /// Split out of `quad` — see `stream_quad_counts`.
2939    pub path_gradient: usize,
2940}
2941
2942impl StreamQuadCounts {
2943    /// The largest per-pipeline count — sizes the shared index buffer
2944    /// so one index stream serves all pipelines.
2945    pub fn max(&self) -> usize {
2946        self.rect
2947            .max(self.sdf)
2948            .max(self.quad)
2949            .max(self.shadow)
2950            .max(self.anim_proc)
2951            .max(self.path_gradient)
2952    }
2953}
2954
2955/// Count the quads each pipeline's stream buffer must hold for `frame`.
2956///
2957/// - `rect` draws both `DrawCommand::Decoration` (Tier-1 rects) AND
2958///   `DrawCommand::CosmeticLine` (each hairline emits one 4-vertex quad
2959///   through the same rect stream — see the CosmeticLine arm in the
2960///   draw walk).
2961/// - `quad` covers glyphs, SOLID-filled paths, images, plus one
2962///   composite-blit quad per blur scope (`BeginBlurredSubtree`), emitted
2963///   on End. Gradient-filled paths are split out into `path_gradient`
2964///   instead (see below) — they draw through a different pipeline.
2965/// - `anim_proc` covers BOTH animated-quad classes: `Procedural` quads
2966///   batch into `anim_proc_batch`, but `Sprite` quads ALSO write their
2967///   4 vertices into the same `streams.anim_proc` buffer (one
2968///   individually-bound draw each). Counting only `Procedural` here
2969///   undersized the buffer whenever a sprite-animated icon was on
2970///   screen, overflowing the stream at write time.
2971/// - `path_gradient` covers `PathEntry`s whose `paint_data` is a
2972///   gradient variant (`LinearGradient`/`RadialGradient`/`ConicGradient`)
2973///   — drawn by the dedicated `path_gradient_pipeline` instead of the
2974///   shared `quad_pipeline`. Solid paths (`PaintData::Solid`, including
2975///   every stroke) stay counted under `quad`.
2976pub(crate) fn stream_quad_counts(frame: &RenderFrame) -> StreamQuadCounts {
2977    let composite_quads = frame
2978        .draw_order
2979        .iter()
2980        .filter(|c| matches!(c, teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }))
2981        .count();
2982    let gradient_paths = frame
2983        .paths
2984        .iter()
2985        .filter(|p| !matches!(p.paint_data, teksilo_canvas::PaintData::Solid))
2986        .count();
2987    let solid_paths = frame.paths.len() - gradient_paths;
2988    StreamQuadCounts {
2989        rect: frame.decorations.len() + frame.cosmetic_lines.len(),
2990        sdf: frame.shapes.len(),
2991        quad: frame.glyphs.len() + solid_paths + frame.images.len() + composite_quads,
2992        shadow: frame.shadows.len(),
2993        anim_proc: frame.animated_quads.len(),
2994        path_gradient: gradient_paths,
2995    }
2996}
2997
2998/// Build the procedural-animation pipeline plus its per-slot uniform
2999/// buffer, bind group, and bind-group layout. The layout is returned
3000/// so the sprite pipeline can reuse it as its `group 0`. Buffer is
3001/// sized for [`MAX_ANIM_SLOTS`] × `size_of::<teksilo_canvas::AnimParams>()`;
3002/// the per-frame upload in `render()` truncates writes past that cap.
3003fn create_anim_proc_pipeline(
3004    device: &wgpu::Device,
3005    format: wgpu::TextureFormat,
3006) -> (
3007    wgpu::RenderPipeline,
3008    wgpu::Buffer,
3009    wgpu::BindGroup,
3010    wgpu::BindGroupLayout,
3011) {
3012    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3013        label: Some("anim_procedural_shader"),
3014        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/anim_procedural.wgsl").into()),
3015    });
3016
3017    let buffer_size = (MAX_ANIM_SLOTS * std::mem::size_of::<teksilo_canvas::AnimParams>()) as u64;
3018    let anim_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3019        label: Some("anim_uniform_buffer"),
3020        size: buffer_size,
3021        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3022        mapped_at_creation: false,
3023    });
3024
3025    let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
3026        label: Some("anim_uniform_bind_group_layout"),
3027        entries: &[wgpu::BindGroupLayoutEntry {
3028            binding: 0,
3029            visibility: wgpu::ShaderStages::FRAGMENT,
3030            ty: wgpu::BindingType::Buffer {
3031                ty: wgpu::BufferBindingType::Uniform,
3032                has_dynamic_offset: false,
3033                min_binding_size: None,
3034            },
3035            count: None,
3036        }],
3037    });
3038
3039    let anim_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3040        label: Some("anim_uniform_bind_group"),
3041        layout: &bind_group_layout,
3042        entries: &[wgpu::BindGroupEntry {
3043            binding: 0,
3044            resource: anim_uniform_buffer.as_entire_binding(),
3045        }],
3046    });
3047
3048    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3049        label: Some("anim_proc_pipeline_layout"),
3050        bind_group_layouts: &[Some(&bind_group_layout)],
3051        immediate_size: 0,
3052    });
3053
3054    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3055        label: Some("anim_proc_pipeline"),
3056        layout: Some(&pipeline_layout),
3057        vertex: wgpu::VertexState {
3058            module: &shader,
3059            entry_point: Some("vs_main"),
3060            buffers: &[Some(anim_quad_vertex_layout())],
3061            compilation_options: Default::default(),
3062        },
3063        fragment: Some(wgpu::FragmentState {
3064            module: &shader,
3065            entry_point: Some("fs_main"),
3066            targets: &[Some(wgpu::ColorTargetState {
3067                format,
3068                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3069                write_mask: wgpu::ColorWrites::ALL,
3070            })],
3071            compilation_options: Default::default(),
3072        }),
3073        primitive: wgpu::PrimitiveState {
3074            topology: wgpu::PrimitiveTopology::TriangleList,
3075            ..Default::default()
3076        },
3077        depth_stencil: None,
3078        multisample: wgpu::MultisampleState::default(),
3079        multiview_mask: None,
3080        cache: None,
3081    });
3082
3083    (
3084        pipeline,
3085        anim_uniform_buffer,
3086        anim_uniform_bind_group,
3087        bind_group_layout,
3088    )
3089}
3090
3091/// Build the sprite-atlas animation pipeline. Shares group 0 (the
3092/// per-slot uniform buffer) with the procedural pipeline; adds group
3093/// 1 = sprite atlas texture + sampler, resolved per-draw via
3094/// `ImageManager::get_bind_group(image_name)`. Returns the pipeline
3095/// and the texture bind-group layout (so `ImageManager` can register
3096/// images under the same layout).
3097fn create_anim_sprite_pipeline(
3098    device: &wgpu::Device,
3099    format: wgpu::TextureFormat,
3100    uniform_layout: &wgpu::BindGroupLayout,
3101    texture_layout: &wgpu::BindGroupLayout,
3102) -> wgpu::RenderPipeline {
3103    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3104        label: Some("anim_sprite_shader"),
3105        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/anim_sprite.wgsl").into()),
3106    });
3107
3108    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3109        label: Some("anim_sprite_pipeline_layout"),
3110        bind_group_layouts: &[Some(uniform_layout), Some(texture_layout)],
3111        immediate_size: 0,
3112    });
3113
3114    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3115        label: Some("anim_sprite_pipeline"),
3116        layout: Some(&pipeline_layout),
3117        vertex: wgpu::VertexState {
3118            module: &shader,
3119            entry_point: Some("vs_main"),
3120            buffers: &[Some(anim_quad_vertex_layout())],
3121            compilation_options: Default::default(),
3122        },
3123        fragment: Some(wgpu::FragmentState {
3124            module: &shader,
3125            entry_point: Some("fs_main"),
3126            targets: &[Some(wgpu::ColorTargetState {
3127                format,
3128                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3129                write_mask: wgpu::ColorWrites::ALL,
3130            })],
3131            compilation_options: Default::default(),
3132        }),
3133        primitive: wgpu::PrimitiveState {
3134            topology: wgpu::PrimitiveTopology::TriangleList,
3135            ..Default::default()
3136        },
3137        depth_stencil: None,
3138        multisample: wgpu::MultisampleState::default(),
3139        multiview_mask: None,
3140        cache: None,
3141    })
3142}
3143
3144/// Vertex buffer layout shared by both animated-quad pipelines.
3145fn anim_quad_vertex_layout() -> wgpu::VertexBufferLayout<'static> {
3146    const ATTRS: [wgpu::VertexAttribute; 3] = [
3147        wgpu::VertexAttribute {
3148            offset: 0,
3149            shader_location: 0,
3150            format: wgpu::VertexFormat::Float32x2,
3151        },
3152        wgpu::VertexAttribute {
3153            offset: 8,
3154            shader_location: 1,
3155            format: wgpu::VertexFormat::Float32x2,
3156        },
3157        wgpu::VertexAttribute {
3158            offset: 16,
3159            shader_location: 2,
3160            format: wgpu::VertexFormat::Uint32,
3161        },
3162    ];
3163    wgpu::VertexBufferLayout {
3164        array_stride: std::mem::size_of::<AnimQuadVertex>() as u64,
3165        step_mode: wgpu::VertexStepMode::Vertex,
3166        attributes: &ATTRS,
3167    }
3168}
3169
3170#[cfg(test)]
3171mod tests {
3172    use teksilo_canvas::RenderFrame;
3173    use teksilo_canvas::render_frame::{DrawCommand, GlyphQuad, PaintData, ShapeKind, ShapeQuad};
3174
3175    use super::*;
3176
3177    #[test]
3178    fn stream_quad_counts_includes_sprite_anim_quads() {
3179        // Regression test for the anim_proc undercount: Sprite-class
3180        // animated quads write 4 vertices into the SAME stream buffer
3181        // as Procedural ones (each sprite draws individually, but the
3182        // bytes land in `streams.anim_proc`). Sizing for Procedural
3183        // only overflowed the stream whenever a sprite-animated icon
3184        // was on screen.
3185        use teksilo_canvas::render_frame::{AnimatedQuadClass, AnimatedQuadDraw};
3186
3187        let mut frame = RenderFrame::new();
3188        for slot in 0..3 {
3189            frame.animated_quads.push(AnimatedQuadDraw {
3190                screen: [0.0, 0.0, 10.0, 10.0],
3191                slot,
3192                class: AnimatedQuadClass::Procedural,
3193            });
3194        }
3195        for slot in 3..5 {
3196            frame.animated_quads.push(AnimatedQuadDraw {
3197                screen: [0.0, 0.0, 10.0, 10.0],
3198                slot,
3199                class: AnimatedQuadClass::Sprite {
3200                    image_name: "icon".to_string(),
3201                },
3202            });
3203        }
3204        frame.glyphs.push(GlyphQuad {
3205            screen: [0.0, 0.0, 8.0, 8.0],
3206            atlas: [0.0, 0.0, 2.0, 2.0],
3207            color: [1.0; 4],
3208            is_color: false,
3209        });
3210
3211        let counts = stream_quad_counts(&frame);
3212        assert_eq!(
3213            counts.anim_proc, 5,
3214            "anim_proc stream must be sized for BOTH Procedural and Sprite quads"
3215        );
3216        assert_eq!(counts.quad, 1);
3217        assert_eq!(counts.rect, 0);
3218        assert_eq!(counts.sdf, 0);
3219        assert_eq!(counts.shadow, 0);
3220        assert_eq!(counts.max(), 5, "index buffer sizes to the largest stream");
3221    }
3222
3223    #[test]
3224    fn stream_quad_counts_splits_solid_and_gradient_paths() {
3225        // C4.5: gradient-filled paths draw through a different pipeline
3226        // (`path_gradient_pipeline`) than solid-filled ones (which stay
3227        // on `quad_pipeline`), so the two must size DIFFERENT stream
3228        // buffers — undercounting either overflows its `StreamBuffer`
3229        // at write time (see `StreamBuffer::write`'s debug_assert).
3230        use teksilo_canvas::render_frame::PathEntry;
3231        use teksilo_canvas::{FillRule, GradientStop, Path, StrokeStyle};
3232        use teksilo_tokens::Color;
3233
3234        let mut frame = RenderFrame::new();
3235        frame.paths.push(PathEntry {
3236            path: Path::new(),
3237            color: [1.0, 0.0, 0.0, 1.0],
3238            stroke_style: StrokeStyle::solid(0.0),
3239            fill_rule: FillRule::Winding,
3240            bounds: [0.0, 0.0, 10.0, 10.0],
3241            paint_data: PaintData::Solid,
3242        });
3243        frame.paths.push(PathEntry {
3244            path: Path::new(),
3245            color: [1.0, 1.0, 1.0, 1.0],
3246            stroke_style: StrokeStyle::solid(0.0),
3247            fill_rule: FillRule::Winding,
3248            bounds: [0.0, 0.0, 20.0, 20.0],
3249            paint_data: PaintData::LinearGradient {
3250                start: [0.0, 0.0],
3251                end: [20.0, 0.0],
3252                stops: vec![
3253                    GradientStop {
3254                        offset: 0.0,
3255                        color: Color::RED,
3256                    },
3257                    GradientStop {
3258                        offset: 1.0,
3259                        color: Color::BLUE,
3260                    },
3261                ],
3262            },
3263        });
3264
3265        let counts = stream_quad_counts(&frame);
3266        assert_eq!(counts.quad, 1, "the solid path counts toward quad");
3267        assert_eq!(
3268            counts.path_gradient, 1,
3269            "the gradient path counts toward path_gradient, not quad"
3270        );
3271        assert_eq!(counts.rect, 0);
3272        assert_eq!(counts.sdf, 0);
3273        assert_eq!(counts.shadow, 0);
3274        assert_eq!(counts.anim_proc, 0);
3275        assert_eq!(counts.max(), 1);
3276    }
3277
3278    #[test]
3279    fn gradient_path_renders_nonflat_on_gpu() {
3280        // #12 end-to-end GPU verification: a gradient-filled Tier-3 path must
3281        // flush through the dedicated `path_gradient` pipeline and produce a
3282        // real gradient (not a flat tint) on an actual device — and without
3283        // tripping `StreamBuffer::write`'s capacity debug_assert. This is the
3284        // one property headless-CPU tests structurally cannot prove; it needs
3285        // a real device + pixel readback.
3286        use teksilo_canvas::render_frame::PathEntry;
3287        use teksilo_canvas::{FillRule, GradientStop, Path, Rect, StrokeStyle};
3288        use teksilo_tokens::Color;
3289
3290        let Some((mut renderer, device, queue)) = pollster::block_on(
3291            crate::test_support::create_test_renderer("teksilo_render_gradient_path_device"),
3292        ) else {
3293            return; // no GPU adapter (headless CI) — skip.
3294        };
3295
3296        // A filled 30×30 square, horizontally red (left) → blue (right).
3297        let path = Path::rect(Rect::new(1.0, 1.0, 30.0, 30.0));
3298        let bounds = path.bounds();
3299        let mut frame = RenderFrame::new();
3300        frame.paths.push(PathEntry {
3301            path,
3302            color: [1.0, 1.0, 1.0, 1.0],
3303            stroke_style: StrokeStyle::solid(0.0),
3304            fill_rule: FillRule::Winding,
3305            bounds: [bounds.x, bounds.y, bounds.width, bounds.height],
3306            paint_data: PaintData::LinearGradient {
3307                start: [bounds.x, bounds.y],
3308                end: [bounds.x + bounds.width, bounds.y],
3309                stops: vec![
3310                    GradientStop {
3311                        offset: 0.0,
3312                        color: Color::RED,
3313                    },
3314                    GradientStop {
3315                        offset: 1.0,
3316                        color: Color::BLUE,
3317                    },
3318                ],
3319            },
3320        });
3321        frame.draw_order.push(DrawCommand::Path(0));
3322
3323        let texture = device.create_texture(&wgpu::TextureDescriptor {
3324            label: Some("teksilo_render_gradient_path_target"),
3325            size: wgpu::Extent3d {
3326                width: 32,
3327                height: 32,
3328                depth_or_array_layers: 1,
3329            },
3330            mip_level_count: 1,
3331            sample_count: 1,
3332            dimension: wgpu::TextureDimension::D2,
3333            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3334            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3335            view_formats: &[],
3336        });
3337        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3338
3339        // Reaching here without a panic means the gradient batch flushed
3340        // without a `StreamBuffer` capacity overflow (the debug_assert the
3341        // count-split guards).
3342        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3343
3344        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3345        let px = |x: usize, y: usize| {
3346            let i = (y * 32 + x) * 4;
3347            [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
3348        };
3349        // Sample a row through the middle: near the red edge and the blue edge.
3350        let left = px(4, 16);
3351        let right = px(27, 16);
3352
3353        assert!(
3354            left[3] > 200 && right[3] > 200,
3355            "gradient square not covered (coverage-mask atlas broken): left={left:?} right={right:?}"
3356        );
3357        // Left red-dominant, right blue-dominant, ends clearly different — a
3358        // real interpolated gradient, not a single flat tint.
3359        assert!(
3360            left[0] as i32 > left[2] as i32 + 40,
3361            "left edge must be red-dominant, got {left:?}"
3362        );
3363        assert!(
3364            right[2] as i32 > right[0] as i32 + 40,
3365            "right edge must be blue-dominant, got {right:?}"
3366        );
3367        assert!(
3368            (left[0] as i32 - right[0] as i32).abs() > 60,
3369            "gradient looks flat (shader not sampling the gradient): left={left:?} right={right:?}"
3370        );
3371    }
3372
3373    #[test]
3374    fn gradient_path_partial_alpha_preserved() {
3375        // Regression for washed-out gradient fills: a gradient stop's alpha
3376        // must survive the path_gradient pipeline. Render a horizontal
3377        // green→green gradient whose LEFT stop is opaque (a=1.0) and RIGHT
3378        // stop is a=0.4, over a TRANSPARENT clear so the read-back alpha IS
3379        // the fill's alpha (no gamma/compositing confound). Left must stay
3380        // ~opaque, right must read ~0.4 (not ~0.24).
3381        use teksilo_canvas::render_frame::PathEntry;
3382        use teksilo_canvas::{FillRule, GradientStop, Path, Rect, StrokeStyle};
3383        use teksilo_tokens::Color;
3384
3385        let Some((mut renderer, device, queue)) = pollster::block_on(
3386            crate::test_support::create_test_renderer("teksilo_render_partial_alpha_device"),
3387        ) else {
3388            return;
3389        };
3390
3391        let path = Path::rect(Rect::new(0.0, 0.0, 32.0, 32.0));
3392        let bounds = path.bounds();
3393        let mut frame = RenderFrame::new();
3394        frame.paths.push(PathEntry {
3395            path,
3396            color: [1.0, 1.0, 1.0, 1.0],
3397            stroke_style: StrokeStyle::solid(0.0),
3398            fill_rule: FillRule::Winding,
3399            bounds: [bounds.x, bounds.y, bounds.width, bounds.height],
3400            paint_data: PaintData::LinearGradient {
3401                start: [0.0, 0.0],
3402                end: [32.0, 0.0],
3403                stops: vec![
3404                    GradientStop {
3405                        offset: 0.0,
3406                        color: Color::from_rgba(0.0, 0.62, 0.45, 1.0),
3407                    },
3408                    GradientStop {
3409                        offset: 1.0,
3410                        color: Color::from_rgba(0.0, 0.62, 0.45, 0.4),
3411                    },
3412                ],
3413            },
3414        });
3415        frame.draw_order.push(DrawCommand::Path(0));
3416
3417        let texture = device.create_texture(&wgpu::TextureDescriptor {
3418            label: Some("partial_alpha_target"),
3419            size: wgpu::Extent3d {
3420                width: 32,
3421                height: 32,
3422                depth_or_array_layers: 1,
3423            },
3424            mip_level_count: 1,
3425            sample_count: 1,
3426            dimension: wgpu::TextureDimension::D2,
3427            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3428            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3429            view_formats: &[],
3430        });
3431        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3432        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3433
3434        let px = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3435        let alpha = |x: usize| px[(16 * 32 + x) * 4 + 3];
3436        let (left, right) = (alpha(2), alpha(29));
3437        // Diagnostic — surfaced on failure.
3438        assert!(
3439            left >= 240,
3440            "opaque (a=1.0) end must stay opaque, got {left} (/255)"
3441        );
3442        assert!(
3443            (90..=115).contains(&right),
3444            "a=0.4 stop must read ~102/255, got {right} — a value near ~61 means the \
3445             pipeline under-renders gradient stop alpha (washed-out fills)"
3446        );
3447    }
3448
3449    #[test]
3450    fn glyph_quad_renders_over_shape_in_offscreen_target() {
3451        let Some((mut renderer, device, queue)) = pollster::block_on(
3452            crate::test_support::create_test_renderer("teksilo_render_test_device"),
3453        ) else {
3454            return;
3455        };
3456
3457        renderer.upload_atlas(
3458            2,
3459            2,
3460            &[
3461                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
3462            ],
3463        );
3464
3465        let mut frame = RenderFrame::new();
3466        frame.shapes.push(ShapeQuad {
3467            screen: [4.0, 4.0, 24.0, 24.0],
3468            color: [0.2, 0.6, 0.9, 1.0],
3469            shape: ShapeKind::RoundedRect,
3470            stroke_width: 0.0,
3471            stroke_space: teksilo_canvas::StrokeSpace::Logical,
3472            corner_radii: [0.0; 4],
3473            paint_data: PaintData::Solid,
3474        });
3475        frame.draw_order.push(DrawCommand::Shape(0));
3476
3477        frame.glyphs.push(GlyphQuad {
3478            screen: [10.0, 10.0, 8.0, 8.0],
3479            atlas: [0.0, 0.0, 2.0, 2.0],
3480            color: [1.0, 1.0, 1.0, 1.0],
3481            is_color: false,
3482        });
3483        frame.draw_order.push(DrawCommand::Glyph(0));
3484
3485        let texture = device.create_texture(&wgpu::TextureDescriptor {
3486            label: Some("teksilo_render_test_target"),
3487            size: wgpu::Extent3d {
3488                width: 32,
3489                height: 32,
3490                depth_or_array_layers: 1,
3491            },
3492            mip_level_count: 1,
3493            sample_count: 1,
3494            dimension: wgpu::TextureDimension::D2,
3495            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3496            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3497            view_formats: &[],
3498        });
3499        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3500
3501        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3502
3503        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3504        let center = ((14 * 32 + 14) * 4) as usize;
3505        let blue_only = [
3506            pixels[center],
3507            pixels[center + 1],
3508            pixels[center + 2],
3509            pixels[center + 3],
3510        ];
3511
3512        assert!(
3513            blue_only[0] > 200 && blue_only[1] > 200 && blue_only[2] > 200,
3514            "expected glyph pixel to be visible over shape, got {:?}",
3515            blue_only
3516        );
3517    }
3518
3519    #[test]
3520    fn fractional_origin_glyph_renders_pixel_exact() {
3521        // Regression test for the linear-sampler blur / bottom-row crop:
3522        // glyph origins are fractional (shaping advances, scroll), and
3523        // with a bilinear atlas sampler an unsnapped 1:1 quad feathers
3524        // every edge and fades its last bitmap row into the transparent
3525        // atlas gutter (visibly cropping the bottom of "c"/"e"). The
3526        // pixel snap in `from_glyph_quad_transformed` must land the quad
3527        // on the integer grid so linear sampling is exact: interior
3528        // pixels fully opaque, surrounding pixels fully transparent.
3529        let Some((mut renderer, device, queue)) = pollster::block_on(
3530            crate::test_support::create_test_renderer("teksilo_render_snap_test_device"),
3531        ) else {
3532            return;
3533        };
3534
3535        // 4×4 atlas: a 3×3 fully-opaque white glyph bitmap at (0,0); the
3536        // remaining row/column transparent (the allocator's 1px gutter).
3537        let mut atlas = [0u8; 4 * 4 * 4];
3538        for y in 0..3 {
3539            for x in 0..3 {
3540                let i = (y * 4 + x) * 4;
3541                atlas[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
3542            }
3543        }
3544        renderer.upload_atlas(4, 4, &atlas);
3545
3546        let mut frame = RenderFrame::new();
3547        // Fractional origin; the snap lands it at (10, 11).
3548        frame.glyphs.push(GlyphQuad {
3549            screen: [10.4, 10.6, 3.0, 3.0],
3550            atlas: [0.0, 0.0, 3.0, 3.0],
3551            color: [1.0, 1.0, 1.0, 1.0],
3552            is_color: false,
3553        });
3554        frame.draw_order.push(DrawCommand::Glyph(0));
3555
3556        let texture = device.create_texture(&wgpu::TextureDescriptor {
3557            label: Some("teksilo_render_snap_test_target"),
3558            size: wgpu::Extent3d {
3559                width: 32,
3560                height: 32,
3561                depth_or_array_layers: 1,
3562            },
3563            mip_level_count: 1,
3564            sample_count: 1,
3565            dimension: wgpu::TextureDimension::D2,
3566            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3567            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3568            view_formats: &[],
3569        });
3570        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3571
3572        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3573
3574        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3575        let alpha = |x: usize, y: usize| pixels[(y * 32 + x) * 4 + 3];
3576
3577        // Interior pixels exactly opaque — in particular the BOTTOM row
3578        // (y = 13), the one the unsnapped bilinear kernel used to fade
3579        // into the gutter.
3580        for y in 11..14 {
3581            for x in 10..13 {
3582                assert_eq!(
3583                    alpha(x, y),
3584                    255,
3585                    "interior pixel ({x},{y}) must be fully opaque — \
3586                     bilinear edge feathering means the snap did not fire"
3587                );
3588            }
3589        }
3590        // The one-pixel ring around the quad exactly transparent — no
3591        // feathered halo on any side.
3592        for y in 10..15 {
3593            for x in 9..14 {
3594                let inside = (10..13).contains(&x) && (11..14).contains(&y);
3595                if !inside {
3596                    assert_eq!(
3597                        alpha(x, y),
3598                        0,
3599                        "ring pixel ({x},{y}) must be untouched — \
3600                         the snapped quad must not bleed past its bitmap"
3601                    );
3602                }
3603            }
3604        }
3605    }
3606
3607    /// The same guarantee for Tier-3 paths, which did not have it.
3608    ///
3609    /// Every SVG icon in an app is a path, and a path's quad used to be
3610    /// derived from `entry.bounds × scale_factor` while its bitmap was baked
3611    /// on its own integer grid. `Rect::expand` alone puts a line-style 16 dp
3612    /// icon's bounds on a half pixel, so the two disagreed by half a texel
3613    /// and the linear sampler smeared every stroke: a 1 px hairline peaked
3614    /// at 48 % coverage instead of 100 %, and a dashed ring's sub-pixel gaps
3615    /// closed up into a grey haze.
3616    ///
3617    /// A 1 px vertical stroke must therefore land as exactly one fully
3618    /// opaque column with nothing either side of it.
3619    #[test]
3620    fn fractional_origin_path_renders_pixel_exact() {
3621        let Some((mut renderer, device, queue)) = pollster::block_on(
3622            crate::test_support::create_test_renderer("teksilo_render_path_snap_test_device"),
3623        ) else {
3624            return;
3625        };
3626
3627        // A hairline centred on x = 8.5, so it covers exactly device column
3628        // 8. Its stroke-expanded bounds start at x = 7.5: the half pixel.
3629        let mut path = teksilo_canvas::Path::new();
3630        path.move_to(teksilo_canvas::Point::new(8.5, 4.0));
3631        path.line_to(teksilo_canvas::Point::new(8.5, 12.0));
3632        let stroke_style = teksilo_canvas::StrokeStyle::solid(1.0);
3633        let bounds = path.bounds().expand(stroke_style.width);
3634        assert_eq!(bounds.x, 7.5, "the half-pixel origin this test is about");
3635
3636        let mut frame = RenderFrame::new();
3637        frame.paths.push(teksilo_canvas::PathEntry {
3638            path,
3639            color: [1.0, 1.0, 1.0, 1.0],
3640            stroke_style,
3641            fill_rule: teksilo_canvas::FillRule::Winding,
3642            bounds: bounds.to_array(),
3643            paint_data: teksilo_canvas::PaintData::Solid,
3644        });
3645        frame.draw_order.push(DrawCommand::Path(0));
3646
3647        let texture = device.create_texture(&wgpu::TextureDescriptor {
3648            label: Some("teksilo_render_path_snap_test_target"),
3649            size: wgpu::Extent3d {
3650                width: 32,
3651                height: 32,
3652                depth_or_array_layers: 1,
3653            },
3654            mip_level_count: 1,
3655            sample_count: 1,
3656            dimension: wgpu::TextureDimension::D2,
3657            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3658            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3659            view_formats: &[],
3660        });
3661        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3662
3663        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3664
3665        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3666        let alpha = |x: usize, y: usize| pixels[(y * 32 + x) * 4 + 3];
3667
3668        for y in 5..11 {
3669            assert_eq!(
3670                alpha(8, y),
3671                255,
3672                "the hairline's own column must be fully inked at y={y} — \
3673                 anything less means the quad was resampled off the pixel grid"
3674            );
3675            for x in [6, 7, 9, 10] {
3676                assert_eq!(
3677                    alpha(x, y),
3678                    0,
3679                    "({x},{y}) must be untouched — a 1 px stroke that leaks \
3680                     into its neighbours is the blur this snap removes"
3681                );
3682            }
3683        }
3684    }
3685}