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