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