Skip to main content

teksilo_render/
renderer.rs

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