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