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