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