teksilo_render/renderer.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use wgpu;
5
6use teksilo_canvas::RenderFrame;
7use teksilo_canvas::geometry::Transform2D;
8
9use crate::blur::{BlurPipelines, BlurPool};
10use crate::image_manager::ImageManager;
11use crate::path_atlas::PathAtlas;
12use crate::stream_buffer::StreamBuffers;
13use crate::vertex::{AnimQuadVertex, QuadVertex, RectVertex, SdfVertex, ShadowVertex};
14
15/// How many animated-quad slots the uniform buffer holds. Must match
16/// the array size in `shaders/anim_procedural.wgsl`. Bumping this
17/// requires updating the WGSL constant too (WGSL array sizes are
18/// static). 128 × 64 B = 8 KiB — well within UBO caps.
19const MAX_ANIM_SLOTS: usize = 128;
20
21/// GPU renderer that draws a RenderFrame using seven shader pipelines.
22pub struct Renderer {
23 device: wgpu::Device,
24 queue: wgpu::Queue,
25 /// `max_texture_dimension_2d` of the device behind `device`.
26 ///
27 /// Cached because both atlases grow to a compiled-in ceiling that a
28 /// downlevel device need not be able to allocate, and the glyph atlas is
29 /// sized by the text backend rather than by this crate.
30 max_texture_dimension: u32,
31 rect_pipeline: wgpu::RenderPipeline,
32 sdf_pipeline: wgpu::RenderPipeline,
33 quad_pipeline: wgpu::RenderPipeline,
34 shadow_pipeline: wgpu::RenderPipeline,
35 /// Gradient-filled path pipeline (Tier 3) — draws `PathEntry`s whose
36 /// `paint_data` is a gradient variant. Solid-filled paths keep using
37 /// the lean `quad_pipeline` above; see `path_gradient_quad_verts` /
38 /// `PathGradientVertex`. Shares its group(0) bind-group layout
39 /// (texture + sampler) with `quad_pipeline`, so it binds the same
40 /// `path_atlas_texture` bind group the solid path-quad batch uses.
41 path_gradient_pipeline: wgpu::RenderPipeline,
42 /// Procedural animated-quad pipeline — IndeterminateSweep,
43 /// SpinnerArc and future Pulse / Shimmer kinds. Binds group 0 to a
44 /// uniform buffer holding an array of `AnimParams` (one per slot).
45 anim_proc_pipeline: wgpu::RenderPipeline,
46 /// Sprite-atlas animated-quad pipeline — frame-cycling for
47 /// `AnimatedQuadKind::SpriteCycle`. Shares the same uniform buffer
48 /// as the procedural pipeline at group 0; group 1 carries the
49 /// per-atlas texture bind group. Reuses the quad_pipeline's
50 /// bind-group layout for group 1, so the bind groups that
51 /// `ImageManager` builds for static images are also usable here
52 /// without a second registration.
53 anim_sprite_pipeline: wgpu::RenderPipeline,
54 /// Uniform buffer backing both animated-quad pipelines' per-slot
55 /// state. Rewritten wholesale at the top of each `render()` from
56 /// `frame.anim_params`. Fixed size (`MAX_ANIM_SLOTS * 64 B`); the
57 /// upload in `render()` truncates if it ever exceeds.
58 anim_uniform_buffer: wgpu::Buffer,
59 /// Bind group for the animated pipelines (group 0 on both).
60 anim_uniform_bind_group: wgpu::BindGroup,
61 atlas_texture: Option<AtlasTexture>,
62 path_atlas: PathAtlas,
63 path_atlas_texture: Option<AtlasTexture>,
64 image_manager: ImageManager,
65 /// Persistent per-pipeline streaming buffers. Resized on demand at
66 /// the top of each `render()` call, then reused via `write_buffer`
67 /// for every batch flush in that frame — replaces the historical
68 /// per-flush `create_buffer_init` antipattern.
69 streams: StreamBuffers,
70 /// Dual-Kawase blur pipelines (downsample + upsample) and per-pass
71 /// uniform buffer. Built once at construction; consumed by the
72 /// `BeginBlurredSubtree` / `EndBlurredSubtree` handler in `render`.
73 blur_pipelines: BlurPipelines,
74 /// Recycled intermediate-texture pool for blur scopes. Begin-of-
75 /// frame resets per-texture in-use flags; textures unused for
76 /// several frames evict.
77 blur_pool: BlurPool,
78 /// Cached bind group layout for the quad pipeline's group(0)
79 /// (texture + sampler). Used to build per-frame bind groups that
80 /// expose blur-pool intermediates as image sources for the
81 /// compositing blit at the end of each blur scope.
82 quad_bind_group_layout: wgpu::BindGroupLayout,
83 /// Sampler used by the blur composite blit. Linear filtering so
84 /// the over-allocated bucket texture's used sub-rect samples
85 /// cleanly when composited onto a non-aligned target rect.
86 blur_composite_sampler: wgpu::Sampler,
87}
88
89struct AtlasTexture {
90 texture: wgpu::Texture,
91 bind_group: wgpu::BindGroup,
92 width: u32,
93 height: u32,
94}
95
96/// Active render target — the bottom of the stack is always the
97/// surface; intermediates push above it for the duration of a blur
98/// scope. Each entry tracks both the target's identity and per-target
99/// state that survives across multiple segment passes against the
100/// same target (e.g. when an inner blur scope ends and we re-open
101/// the parent intermediate to draw additional commands).
102struct ActiveTarget {
103 /// `None` ⇒ surface (the caller-provided texture view).
104 /// `Some(handle)` ⇒ a blur intermediate from `BlurPool`.
105 intermediate: Option<crate::blur::AcquiredTexture>,
106 /// Viewport dimensions for NDC conversion in this scope.
107 viewport_w: u32,
108 viewport_h: u32,
109 /// `false` until the first segment pass against this target runs;
110 /// controls whether the next pass uses Clear or Load.
111 opened: bool,
112 /// Blurred sub-tree results that nested scopes have queued for
113 /// compositing into THIS target on its next segment open. Drained
114 /// at the top of each segment.
115 pending_composites: Vec<PendingComposite>,
116 /// 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 allocates slots without a
503 // ceiling 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 or path atlas;
593 // images draw individually with their own bind group).
594 // Flushed when the bind group source changes.
595 #[derive(Clone, Copy, PartialEq, Eq)]
596 enum QuadSource {
597 GlyphAtlas,
598 PathAtlas,
599 }
600 let mut quad_source: Option<QuadSource> = None;
601
602 // Flush helpers — each writes one batch into the persistent
603 // stream buffer and issues one draw call. The index buffer was
604 // written once at the top of `render()` and is shared.
605 //
606 // `$index_binding` is `Option<(&Buffer, u64 offset, u64 len)>`
607 // — `None` only if the frame had zero quads, in which case
608 // every batch is also empty and the flush is a no-op anyway.
609 macro_rules! flush_stream {
610 ($pass:expr, $queue:expr, $stream:expr, $pipeline:expr,
611 $batch:expr, $index_binding:expr) => {
612 if !$batch.is_empty() {
613 let bytes: &[u8] = bytemuck::cast_slice(&$batch);
614 if let (Some((vb, v_off, v_len)), Some((ib, _, _))) =
615 ($stream.write($queue, bytes), $index_binding)
616 {
617 let quads = ($batch.len() / 4) as u32;
618 let index_count = quads * 6;
619 let index_bytes = (index_count as u64) * 4;
620 $pass.set_pipeline($pipeline);
621 $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
622 $pass.set_index_buffer(
623 ib.slice(0..index_bytes),
624 wgpu::IndexFormat::Uint32,
625 );
626 $pass.draw_indexed(0..index_count, 0, 0..1);
627 }
628 $batch.clear();
629 }
630 };
631 }
632
633 // Flush all pending batches (called on state changes).
634 macro_rules! flush_all {
635 ($pass:expr, $queue:expr, $streams:expr,
636 $rp:expr, $sp:expr, $qp:expr, $pgp:expr, $shp:expr,
637 $rb:expr, $sb:expr, $qb:expr, $pgb:expr, $shb:expr,
638 $atlas:expr, $path_atlas:expr, $qs:expr, $index_binding:expr) => {
639 flush_stream!($pass, $queue, &$streams.rect, $rp, $rb, $index_binding);
640 flush_stream!($pass, $queue, &$streams.sdf, $sp, $sb, $index_binding);
641 // Quad batch needs bind group
642 if !$qb.is_empty() {
643 let bg = match $qs {
644 Some(QuadSource::PathAtlas) => {
645 $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group)
646 }
647 _ => $atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
648 };
649 if let (Some(bind_group), Some((ib, _, _))) = (bg, $index_binding) {
650 let bytes: &[u8] = bytemuck::cast_slice(&$qb);
651 if let Some((vb, v_off, v_len)) = $streams.quad.write($queue, bytes) {
652 let quads = ($qb.len() / 4) as u32;
653 let index_count = quads * 6;
654 let index_bytes = (index_count as u64) * 4;
655 $pass.set_pipeline($qp);
656 $pass.set_bind_group(0, bind_group, &[]);
657 $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
658 $pass.set_index_buffer(
659 ib.slice(0..index_bytes),
660 wgpu::IndexFormat::Uint32,
661 );
662 $pass.draw_indexed(0..index_count, 0, 0..1);
663 }
664 }
665 $qb.clear();
666 }
667 // Gradient-filled path batch. Binds the SAME path
668 // atlas texture bind group the solid path-quad batch
669 // above uses (`$path_atlas`) — the gradient pipeline
670 // reuses `quad_pipeline`'s group(0) layout, so the
671 // bind group is interchangeable.
672 if !$pgb.is_empty() {
673 if let (Some(bind_group), Some((ib, _, _))) = (
674 $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
675 $index_binding,
676 ) {
677 let bytes: &[u8] = bytemuck::cast_slice(&$pgb);
678 if let Some((vb, v_off, v_len)) =
679 $streams.path_gradient.write($queue, bytes)
680 {
681 let quads = ($pgb.len() / 4) as u32;
682 let index_count = quads * 6;
683 let index_bytes = (index_count as u64) * 4;
684 $pass.set_pipeline($pgp);
685 $pass.set_bind_group(0, bind_group, &[]);
686 $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
687 $pass.set_index_buffer(
688 ib.slice(0..index_bytes),
689 wgpu::IndexFormat::Uint32,
690 );
691 $pass.draw_indexed(0..index_count, 0, 0..1);
692 }
693 }
694 $pgb.clear();
695 }
696 flush_stream!($pass, $queue, &$streams.shadow, $shp, $shb, $index_binding);
697 // Animated-quad procedural batch. Unlike the shared
698 // atlas quad pipeline above, this always binds the
699 // same uniform bind group (per-slot state read by
700 // shader) so there's no source-switching. Accesses
701 // `self.anim_proc_pipeline` / `.anim_uniform_bind_group`
702 // and the local `anim_proc_batch` via macro hygiene —
703 // all three are in scope inside `render()` at every
704 // flush_all! call site.
705 if !anim_proc_batch.is_empty()
706 && let Some((ib, _, _)) = $index_binding
707 {
708 let bytes: &[u8] = bytemuck::cast_slice(&anim_proc_batch);
709 if let Some((vb, v_off, v_len)) = $streams.anim_proc.write($queue, bytes) {
710 let quads = (anim_proc_batch.len() / 4) as u32;
711 let index_count = quads * 6;
712 let index_bytes = (index_count as u64) * 4;
713 $pass.set_pipeline(&self.anim_proc_pipeline);
714 $pass.set_bind_group(0, &self.anim_uniform_bind_group, &[]);
715 $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
716 $pass.set_index_buffer(
717 ib.slice(0..index_bytes),
718 wgpu::IndexFormat::Uint32,
719 );
720 $pass.draw_indexed(0..index_count, 0, 0..1);
721 }
722 anim_proc_batch.clear();
723 }
724 };
725 }
726
727 // Draw in painter's order. Outer loop iterates render
728 // segments — one segment per `RenderPass`. A blur Begin/End
729 // boundary opens a new segment. The pass lives in its own
730 // scope so the encoder borrow is released at each boundary
731 // (allowing the next pass open or any in-between Kawase
732 // work on the encoder).
733 let mut cmd_idx = 0;
734 while cmd_idx <= frame.draw_order.len() {
735 // Resolve current target. We `match` the intermediate
736 // handle vs. surface here; the resulting `target_view`
737 // lifetime ties to one of self.blur_pool / `view` arg.
738 let (target_view, load_op): (&wgpu::TextureView, wgpu::LoadOp<wgpu::Color>) = {
739 let t = target_stack
740 .last_mut()
741 .expect("surface target always present");
742 let v: &wgpu::TextureView = match t.intermediate {
743 Some(h) => self.blur_pool.view(h),
744 None => view,
745 };
746 let lo = if t.opened {
747 wgpu::LoadOp::Load
748 } else if t.intermediate.is_none() {
749 wgpu::LoadOp::Clear(surface_clear_color)
750 } else {
751 wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
752 };
753 t.opened = true;
754 viewport_width = t.viewport_w;
755 viewport_height = t.viewport_h;
756 (v, lo)
757 };
758
759 // Drain pending composites — these are blurred sub-tree
760 // results from nested blur scopes that finished while
761 // we weren't drawing into THIS target. They paint first
762 // in the new segment so subsequent commands stack on
763 // top of the blurred quad.
764 let composites_to_draw: Vec<PendingComposite> = std::mem::take(
765 &mut target_stack
766 .last_mut()
767 .expect("target_stack always has the surface target")
768 .pending_composites,
769 );
770
771 {
772 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
773 label: Some("teksilo_segment_pass"),
774 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
775 view: target_view,
776 resolve_target: None,
777 ops: wgpu::Operations {
778 load: load_op,
779 store: wgpu::StoreOp::Store,
780 },
781 depth_slice: None,
782 })],
783 depth_stencil_attachment: None,
784 timestamp_writes: None,
785 occlusion_query_set: None,
786 multiview_mask: None,
787 });
788
789 // Composite pending blurred sub-trees first.
790 for pc in &composites_to_draw {
791 composite_blur_quad(
792 &self.device,
793 &self.queue,
794 &mut pass,
795 &self.blur_pool,
796 &self.quad_pipeline,
797 &self.quad_bind_group_layout,
798 &self.blur_composite_sampler,
799 &self.streams.quad,
800 index_binding,
801 pc.blurred_texture,
802 pc.used_w,
803 pc.used_h,
804 pc.bucket_w,
805 pc.bucket_h,
806 pc.bounds,
807 scale_factor,
808 viewport_width,
809 viewport_height,
810 );
811 // The composite uses the quad pipeline with a
812 // fresh bind group → invalidate any cached
813 // glyph/path-atlas binding for the next quad
814 // batch.
815 quad_source = None;
816 }
817
818 let pass = &mut pass;
819
820 // Inner loop: process commands until we hit a blur
821 // boundary or run out.
822 while cmd_idx < frame.draw_order.len() {
823 let cmd = &frame.draw_order[cmd_idx];
824 if matches!(
825 cmd,
826 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
827 | teksilo_canvas::DrawCommand::EndBlurredSubtree
828 ) {
829 break;
830 }
831 match cmd {
832 teksilo_canvas::DrawCommand::Decoration(idx) => {
833 flush_all!(
834 pass,
835 &self.queue,
836 self.streams,
837 &self.rect_pipeline,
838 &self.sdf_pipeline,
839 &self.quad_pipeline,
840 &self.path_gradient_pipeline,
841 &self.shadow_pipeline,
842 rect_batch,
843 sdf_batch,
844 quad_batch,
845 path_gradient_batch,
846 shadow_batch,
847 self.atlas_texture,
848 self.path_atlas_texture,
849 quad_source,
850 index_binding
851 );
852 quad_source = None;
853 let Some(rect) = frame.decorations.get(*idx) else {
854 continue;
855 };
856 let verts = RectVertex::from_decoration(rect, scale_factor);
857 for v in &verts {
858 let tp = apply_transform_pixel(v.position, ¤t_transform);
859 rect_batch.push(RectVertex {
860 position: pixel_to_ndc(tp, viewport_width, viewport_height),
861 color: [
862 v.color[0],
863 v.color[1],
864 v.color[2],
865 v.color[3] * current_opacity,
866 ],
867 });
868 }
869 }
870 teksilo_canvas::DrawCommand::CosmeticLine(idx) => {
871 flush_all!(
872 pass,
873 &self.queue,
874 self.streams,
875 &self.rect_pipeline,
876 &self.sdf_pipeline,
877 &self.quad_pipeline,
878 &self.path_gradient_pipeline,
879 &self.shadow_pipeline,
880 rect_batch,
881 sdf_batch,
882 quad_batch,
883 path_gradient_batch,
884 shadow_batch,
885 self.atlas_texture,
886 self.path_atlas_texture,
887 quad_source,
888 index_binding
889 );
890 quad_source = None;
891 let Some(line) = frame.cosmetic_lines.get(*idx) else {
892 continue;
893 };
894 // Transform the endpoints (premultiplied by the
895 // HiDPI scale_factor) through the active
896 // transform, then apply a device-pixel thickness
897 // that does NOT scale with the transform's zoom.
898 let p0 = apply_transform_pixel(
899 [line.from[0] * scale_factor, line.from[1] * scale_factor],
900 ¤t_transform,
901 );
902 let p1 = apply_transform_pixel(
903 [line.to[0] * scale_factor, line.to[1] * scale_factor],
904 ¤t_transform,
905 );
906 let thickness = (line.width * scale_factor).max(1.0);
907 let half = thickness * 0.5;
908 let dx = p1[0] - p0[0];
909 let dy = p1[1] - p0[1];
910 let len = (dx * dx + dy * dy).sqrt();
911 if len < 1e-3 {
912 continue;
913 }
914 // Perpendicular unit normal in device space.
915 let nx = -dy / len;
916 let ny = dx / len;
917 // Pixel-snap axis-aligned lines (edge-aligned
918 // center) for crispness; leave diagonals as-is.
919 let (mut a0, mut a1) = (p0, p1);
920 if dy.abs() < 0.5 {
921 let cy = ((p0[1] + p1[1]) * 0.5 - half).round() + half;
922 a0 = [p0[0], cy];
923 a1 = [p1[0], cy];
924 } else if dx.abs() < 0.5 {
925 let cx = ((p0[0] + p1[0]) * 0.5 - half).round() + half;
926 a0 = [cx, p0[1]];
927 a1 = [cx, p1[1]];
928 }
929 let lin = crate::vertex::srgb_to_linear_rgba(line.color);
930 let color = [lin[0], lin[1], lin[2], lin[3] * current_opacity];
931 let corners = [
932 [a0[0] + nx * half, a0[1] + ny * half],
933 [a1[0] + nx * half, a1[1] + ny * half],
934 [a1[0] - nx * half, a1[1] - ny * half],
935 [a0[0] - nx * half, a0[1] - ny * half],
936 ];
937 for pos in corners {
938 rect_batch.push(RectVertex {
939 position: pixel_to_ndc(
940 pos,
941 viewport_width,
942 viewport_height,
943 ),
944 color,
945 });
946 }
947 }
948 teksilo_canvas::DrawCommand::Shape(idx) => {
949 flush_all!(
950 pass,
951 &self.queue,
952 self.streams,
953 &self.rect_pipeline,
954 &self.sdf_pipeline,
955 &self.quad_pipeline,
956 &self.path_gradient_pipeline,
957 &self.shadow_pipeline,
958 rect_batch,
959 sdf_batch,
960 quad_batch,
961 path_gradient_batch,
962 shadow_batch,
963 self.atlas_texture,
964 self.path_atlas_texture,
965 quad_source,
966 index_binding
967 );
968 quad_source = None;
969 let Some(shape) = frame.shapes.get(*idx) else {
970 continue;
971 };
972 // Cosmetic (device-space) borders hold a
973 // constant device-pixel width under zoom: the
974 // body still scales via `current_transform`, but
975 // the SDF stroke param is divided by the active
976 // zoom (the uniform scale of the linear part,
977 // which carries no scale_factor — see
978 // SetTransform). Fills + logical strokes are
979 // unchanged.
980 let verts = if shape.stroke_space
981 == teksilo_canvas::StrokeSpace::Device
982 && shape.stroke_width > 0.0
983 {
984 // Uniform scale of the linear part = view
985 // zoom (no scale_factor — it lives only in
986 // the translation column). `from_shape_quad_cosmetic`
987 // applies the divide-by-zero floor.
988 let zoom = current_transform.m[0].hypot(current_transform.m[1]);
989 SdfVertex::from_shape_quad_cosmetic(shape, scale_factor, zoom)
990 } else {
991 SdfVertex::from_shape_quad(shape, scale_factor)
992 };
993 for v in &verts {
994 let tp = apply_transform_pixel(v.position, ¤t_transform);
995 sdf_batch.push(SdfVertex {
996 position: pixel_to_ndc(tp, viewport_width, viewport_height),
997 color: [
998 v.color[0],
999 v.color[1],
1000 v.color[2],
1001 v.color[3] * current_opacity,
1002 ],
1003 ..*v
1004 });
1005 }
1006 }
1007 teksilo_canvas::DrawCommand::Glyph(idx) => {
1008 // Only flush when the quad source changes — consecutive
1009 // glyphs batch into one draw call.
1010 if quad_source != Some(QuadSource::GlyphAtlas) {
1011 flush_all!(
1012 pass,
1013 &self.queue,
1014 self.streams,
1015 &self.rect_pipeline,
1016 &self.sdf_pipeline,
1017 &self.quad_pipeline,
1018 &self.path_gradient_pipeline,
1019 &self.shadow_pipeline,
1020 rect_batch,
1021 sdf_batch,
1022 quad_batch,
1023 path_gradient_batch,
1024 shadow_batch,
1025 self.atlas_texture,
1026 self.path_atlas_texture,
1027 quad_source,
1028 index_binding
1029 );
1030 quad_source = Some(QuadSource::GlyphAtlas);
1031 }
1032 if let Some(atlas) = &self.atlas_texture {
1033 let Some(glyph) = frame.glyphs.get(*idx) else {
1034 continue;
1035 };
1036 // Transform is applied (and 1:1 quads
1037 // pixel-snapped) inside the constructor.
1038 let verts = QuadVertex::from_glyph_quad_transformed(
1039 glyph,
1040 scale_factor,
1041 atlas.width,
1042 atlas.height,
1043 ¤t_transform,
1044 );
1045 for v in &verts {
1046 quad_batch.push(QuadVertex {
1047 position: pixel_to_ndc(
1048 v.position,
1049 viewport_width,
1050 viewport_height,
1051 ),
1052 color: [
1053 v.color[0],
1054 v.color[1],
1055 v.color[2],
1056 v.color[3] * current_opacity,
1057 ],
1058 ..*v
1059 });
1060 }
1061 }
1062 }
1063 teksilo_canvas::DrawCommand::Shadow(idx) => {
1064 flush_all!(
1065 pass,
1066 &self.queue,
1067 self.streams,
1068 &self.rect_pipeline,
1069 &self.sdf_pipeline,
1070 &self.quad_pipeline,
1071 &self.path_gradient_pipeline,
1072 &self.shadow_pipeline,
1073 rect_batch,
1074 sdf_batch,
1075 quad_batch,
1076 path_gradient_batch,
1077 shadow_batch,
1078 self.atlas_texture,
1079 self.path_atlas_texture,
1080 quad_source,
1081 index_binding
1082 );
1083 quad_source = None;
1084 let Some(shadow) = frame.shadows.get(*idx) else {
1085 continue;
1086 };
1087 let verts = ShadowVertex::from_shadow_quad(shadow, scale_factor);
1088 for v in &verts {
1089 let tp = apply_transform_pixel(v.position, ¤t_transform);
1090 shadow_batch.push(ShadowVertex {
1091 position: pixel_to_ndc(tp, viewport_width, viewport_height),
1092 shadow_color: [
1093 v.shadow_color[0],
1094 v.shadow_color[1],
1095 v.shadow_color[2],
1096 v.shadow_color[3] * current_opacity,
1097 ],
1098 ..*v
1099 });
1100 }
1101 }
1102 teksilo_canvas::DrawCommand::Image(idx) => {
1103 // Images use per-image bind groups — flush and draw individually
1104 flush_all!(
1105 pass,
1106 &self.queue,
1107 self.streams,
1108 &self.rect_pipeline,
1109 &self.sdf_pipeline,
1110 &self.quad_pipeline,
1111 &self.path_gradient_pipeline,
1112 &self.shadow_pipeline,
1113 rect_batch,
1114 sdf_batch,
1115 quad_batch,
1116 path_gradient_batch,
1117 shadow_batch,
1118 self.atlas_texture,
1119 self.path_atlas_texture,
1120 quad_source,
1121 index_binding
1122 );
1123 quad_source = None;
1124 let Some(image) = frame.images.get(*idx) else {
1125 continue;
1126 };
1127 self.draw_image(
1128 pass,
1129 image,
1130 scale_factor,
1131 viewport_width,
1132 viewport_height,
1133 current_opacity,
1134 ¤t_transform,
1135 index_binding,
1136 );
1137 }
1138 teksilo_canvas::DrawCommand::Path(idx) => {
1139 flush_all!(
1140 pass,
1141 &self.queue,
1142 self.streams,
1143 &self.rect_pipeline,
1144 &self.sdf_pipeline,
1145 &self.quad_pipeline,
1146 &self.path_gradient_pipeline,
1147 &self.shadow_pipeline,
1148 rect_batch,
1149 sdf_batch,
1150 quad_batch,
1151 path_gradient_batch,
1152 shadow_batch,
1153 self.atlas_texture,
1154 self.path_atlas_texture,
1155 quad_source,
1156 index_binding
1157 );
1158 quad_source = None;
1159 if let Some(Some(placement)) = path_placements.get(*idx) {
1160 let Some(entry) = frame.paths.get(*idx) else {
1161 continue;
1162 };
1163 let Some(path_atlas) = self.path_atlas_texture.as_ref() else {
1164 continue;
1165 };
1166 if matches!(entry.paint_data, teksilo_canvas::PaintData::Solid)
1167 {
1168 // Solid fill or solid stroke: the lean
1169 // quad_pipeline, tinted by entry.color.
1170 // (A gradient *stroke* takes the branch
1171 // below — the pipeline choice follows the
1172 // paint, not fill-vs-stroke; the coverage
1173 // mask in the atlas is already whichever
1174 // one this entry rasterized.)
1175 quad_source = Some(QuadSource::PathAtlas);
1176 let verts = path_quad_verts(
1177 entry,
1178 placement,
1179 path_atlas.width,
1180 path_atlas.height,
1181 current_opacity,
1182 ¤t_transform,
1183 );
1184 for v in &verts {
1185 quad_batch.push(QuadVertex {
1186 position: pixel_to_ndc(
1187 v.position,
1188 viewport_width,
1189 viewport_height,
1190 ),
1191 ..*v
1192 });
1193 }
1194 } else {
1195 // Gradient fill: the dedicated
1196 // path_gradient pipeline, which
1197 // samples the SAME atlas coverage
1198 // mask but computes an analytic
1199 // gradient color instead of a flat
1200 // tint.
1201 let verts = path_gradient_quad_verts(
1202 entry,
1203 placement,
1204 scale_factor,
1205 path_atlas.width,
1206 path_atlas.height,
1207 current_opacity,
1208 ¤t_transform,
1209 );
1210 for v in &verts {
1211 path_gradient_batch.push(
1212 crate::vertex::PathGradientVertex {
1213 position: pixel_to_ndc(
1214 v.position,
1215 viewport_width,
1216 viewport_height,
1217 ),
1218 ..*v
1219 },
1220 );
1221 }
1222 }
1223 }
1224 }
1225 // --- State changes flush all batches ---
1226 teksilo_canvas::DrawCommand::SetClip(rect) => {
1227 flush_all!(
1228 pass,
1229 &self.queue,
1230 self.streams,
1231 &self.rect_pipeline,
1232 &self.sdf_pipeline,
1233 &self.quad_pipeline,
1234 &self.path_gradient_pipeline,
1235 &self.shadow_pipeline,
1236 rect_batch,
1237 sdf_batch,
1238 quad_batch,
1239 path_gradient_batch,
1240 shadow_batch,
1241 self.atlas_texture,
1242 self.path_atlas_texture,
1243 quad_source,
1244 index_binding
1245 );
1246 quad_source = None;
1247 // Apply the current transform stack to the
1248 // clip rect. Without this, a clip emitted
1249 // inside a SceneView's view-transform scope
1250 // (e.g. ScrollArea or nested SceneView as
1251 // a heavyweight scene_rect widget) would
1252 // mask the rendered content to the rect's
1253 // PRE-transform position — the contents
1254 // visually pan/zoom with the outer view but
1255 // the clip mask stays fixed in screen
1256 // space, "eating" the widget as the user
1257 // pans or zooms out.
1258 //
1259 // Rotation-free transforms (the common case
1260 // for SceneView pan + zoom) produce an
1261 // axis-aligned transformed rect; for rotated
1262 // transforms we take the AABB of the four
1263 // corners, which over-clips slightly but
1264 // remains correct for visibility.
1265 let p_tl =
1266 apply_transform_pixel([rect.x, rect.y], ¤t_transform);
1267 let p_tr = apply_transform_pixel(
1268 [rect.x + rect.width, rect.y],
1269 ¤t_transform,
1270 );
1271 let p_bl = apply_transform_pixel(
1272 [rect.x, rect.y + rect.height],
1273 ¤t_transform,
1274 );
1275 let p_br = apply_transform_pixel(
1276 [rect.x + rect.width, rect.y + rect.height],
1277 ¤t_transform,
1278 );
1279 let min_x = p_tl[0].min(p_tr[0]).min(p_bl[0]).min(p_br[0]);
1280 let min_y = p_tl[1].min(p_tr[1]).min(p_bl[1]).min(p_br[1]);
1281 let max_x = p_tl[0].max(p_tr[0]).max(p_bl[0]).max(p_br[0]);
1282 let max_y = p_tl[1].max(p_tr[1]).max(p_bl[1]).max(p_br[1]);
1283 let x = (min_x * scale_factor).max(0.0) as u32;
1284 let y = (min_y * scale_factor).max(0.0) as u32;
1285 let w = ((max_x - min_x) * scale_factor).ceil().max(0.0) as u32;
1286 let h = ((max_y - min_y) * scale_factor).ceil().max(0.0) as u32;
1287 // Clamp to viewport — wgpu requires x+w <= width, y+h <= height.
1288 let x = x.min(viewport_width);
1289 let y = y.min(viewport_height);
1290 let w = w.min(viewport_width.saturating_sub(x));
1291 let h = h.min(viewport_height.saturating_sub(y));
1292 let clipped = if let Some(&[cx, cy, cw, ch]) = clip_stack.last() {
1293 let ix = x.max(cx);
1294 let iy = y.max(cy);
1295 let ir = (x + w).min(cx + cw);
1296 let ib = (y + h).min(cy + ch);
1297 [ix, iy, ir.saturating_sub(ix), ib.saturating_sub(iy)]
1298 } else {
1299 [x, y, w, h]
1300 };
1301 clip_stack.push(clipped);
1302 pass.set_scissor_rect(
1303 clipped[0], clipped[1], clipped[2], clipped[3],
1304 );
1305 }
1306 teksilo_canvas::DrawCommand::ClearClip => {
1307 flush_all!(
1308 pass,
1309 &self.queue,
1310 self.streams,
1311 &self.rect_pipeline,
1312 &self.sdf_pipeline,
1313 &self.quad_pipeline,
1314 &self.path_gradient_pipeline,
1315 &self.shadow_pipeline,
1316 rect_batch,
1317 sdf_batch,
1318 quad_batch,
1319 path_gradient_batch,
1320 shadow_batch,
1321 self.atlas_texture,
1322 self.path_atlas_texture,
1323 quad_source,
1324 index_binding
1325 );
1326 quad_source = None;
1327 clip_stack.pop();
1328 if let Some(&[x, y, w, h]) = clip_stack.last() {
1329 pass.set_scissor_rect(x, y, w, h);
1330 } else {
1331 pass.set_scissor_rect(0, 0, viewport_width, viewport_height);
1332 }
1333 }
1334 teksilo_canvas::DrawCommand::SetOpacity(opacity) => {
1335 flush_all!(
1336 pass,
1337 &self.queue,
1338 self.streams,
1339 &self.rect_pipeline,
1340 &self.sdf_pipeline,
1341 &self.quad_pipeline,
1342 &self.path_gradient_pipeline,
1343 &self.shadow_pipeline,
1344 rect_batch,
1345 sdf_batch,
1346 quad_batch,
1347 path_gradient_batch,
1348 shadow_batch,
1349 self.atlas_texture,
1350 self.path_atlas_texture,
1351 quad_source,
1352 index_binding
1353 );
1354 quad_source = None;
1355 opacity_stack.push(current_opacity);
1356 current_opacity *= opacity;
1357 }
1358 teksilo_canvas::DrawCommand::RestoreOpacity => {
1359 flush_all!(
1360 pass,
1361 &self.queue,
1362 self.streams,
1363 &self.rect_pipeline,
1364 &self.sdf_pipeline,
1365 &self.quad_pipeline,
1366 &self.path_gradient_pipeline,
1367 &self.shadow_pipeline,
1368 rect_batch,
1369 sdf_batch,
1370 quad_batch,
1371 path_gradient_batch,
1372 shadow_batch,
1373 self.atlas_texture,
1374 self.path_atlas_texture,
1375 quad_source,
1376 index_binding
1377 );
1378 quad_source = None;
1379 current_opacity = opacity_stack.pop().unwrap_or(1.0);
1380 }
1381 teksilo_canvas::DrawCommand::Rasterized(_) => {}
1382 teksilo_canvas::DrawCommand::AnimatedQuad(idx) => {
1383 let Some(draw) = frame.animated_quads.get(*idx) else {
1384 continue;
1385 };
1386 // Flush every other pipeline first so painter's
1387 // order is preserved across pipeline boundaries.
1388 flush_all!(
1389 pass,
1390 &self.queue,
1391 self.streams,
1392 &self.rect_pipeline,
1393 &self.sdf_pipeline,
1394 &self.quad_pipeline,
1395 &self.path_gradient_pipeline,
1396 &self.shadow_pipeline,
1397 rect_batch,
1398 sdf_batch,
1399 quad_batch,
1400 path_gradient_batch,
1401 shadow_batch,
1402 self.atlas_texture,
1403 self.path_atlas_texture,
1404 quad_source,
1405 index_binding
1406 );
1407 quad_source = None;
1408 match &draw.class {
1409 teksilo_canvas::AnimatedQuadClass::Procedural => {
1410 let verts =
1411 AnimQuadVertex::from_animated_quad(draw, scale_factor);
1412 for v in &verts {
1413 let tp = apply_transform_pixel(
1414 v.position,
1415 ¤t_transform,
1416 );
1417 anim_proc_batch.push(AnimQuadVertex {
1418 position: pixel_to_ndc(
1419 tp,
1420 viewport_width,
1421 viewport_height,
1422 ),
1423 uv: v.uv,
1424 slot: v.slot,
1425 _pad: v._pad,
1426 });
1427 }
1428 }
1429 teksilo_canvas::AnimatedQuadClass::Sprite { image_name } => {
1430 // Sprite quads need a per-atlas bind
1431 // group, so each draws individually —
1432 // same shape as the static Image path.
1433 // Typical scene has ~1 animated sprite
1434 // icon at a time, so batching is moot.
1435 let Some(atlas_bg) =
1436 self.image_manager.get_bind_group(image_name)
1437 else {
1438 continue;
1439 };
1440 let verts =
1441 AnimQuadVertex::from_animated_quad(draw, scale_factor);
1442 let mut ndc_verts = [AnimQuadVertex {
1443 position: [0.0; 2],
1444 uv: [0.0; 2],
1445 slot: 0,
1446 _pad: 0,
1447 };
1448 4];
1449 for (i, v) in verts.iter().enumerate() {
1450 let tp = apply_transform_pixel(
1451 v.position,
1452 ¤t_transform,
1453 );
1454 ndc_verts[i] = AnimQuadVertex {
1455 position: pixel_to_ndc(
1456 tp,
1457 viewport_width,
1458 viewport_height,
1459 ),
1460 uv: v.uv,
1461 slot: v.slot,
1462 _pad: v._pad,
1463 };
1464 }
1465 let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1466 if let (Some((vb, v_off, v_len)), Some((ib, _, _))) = (
1467 self.streams.anim_proc.write(&self.queue, bytes),
1468 index_binding,
1469 ) {
1470 let index_bytes: u64 = 6 * 4;
1471 pass.set_pipeline(&self.anim_sprite_pipeline);
1472 pass.set_bind_group(
1473 0,
1474 &self.anim_uniform_bind_group,
1475 &[],
1476 );
1477 pass.set_bind_group(1, atlas_bg, &[]);
1478 pass.set_vertex_buffer(
1479 0,
1480 vb.slice(v_off..v_off + v_len),
1481 );
1482 pass.set_index_buffer(
1483 ib.slice(0..index_bytes),
1484 wgpu::IndexFormat::Uint32,
1485 );
1486 pass.draw_indexed(0..6, 0, 0..1);
1487 }
1488 }
1489 }
1490 }
1491 teksilo_canvas::DrawCommand::SetBlendMode(mode) => {
1492 blend_stack.push(current_blend);
1493 current_blend = *mode;
1494 }
1495 teksilo_canvas::DrawCommand::RestoreBlendMode => {
1496 current_blend = blend_stack
1497 .pop()
1498 .unwrap_or(teksilo_canvas::BlendMode::Normal);
1499 }
1500 teksilo_canvas::DrawCommand::SetTransform(t) => {
1501 flush_all!(
1502 pass,
1503 &self.queue,
1504 self.streams,
1505 &self.rect_pipeline,
1506 &self.sdf_pipeline,
1507 &self.quad_pipeline,
1508 &self.path_gradient_pipeline,
1509 &self.shadow_pipeline,
1510 rect_batch,
1511 sdf_batch,
1512 quad_batch,
1513 path_gradient_batch,
1514 shadow_batch,
1515 self.atlas_texture,
1516 self.path_atlas_texture,
1517 quad_source,
1518 index_binding
1519 );
1520 quad_source = None;
1521 // Widgets author transforms in logical pixels, but
1522 // vertices arrive pre-multiplied by scale_factor (HiDPI
1523 // device pixels). Scale the translation column so the
1524 // pivot lands at the same physical point in either
1525 // coordinate space.
1526 let device_t = Transform2D {
1527 m: [
1528 t.m[0],
1529 t.m[1],
1530 t.m[2],
1531 t.m[3],
1532 t.m[4] * scale_factor,
1533 t.m[5] * scale_factor,
1534 ],
1535 };
1536 // Compose with the current transform-stack top so a
1537 // widget's canvas-local transform respects any wrapper
1538 // transform pushed by the render walker. With an
1539 // identity stack top this is identical to the old
1540 // "absolute" semantics — backwards compatible for any
1541 // widget not under a transform scope.
1542 let stack_top = transform_stack
1543 .last()
1544 .copied()
1545 .unwrap_or(Transform2D::IDENTITY);
1546 current_transform = device_t.then(&stack_top);
1547 }
1548 teksilo_canvas::DrawCommand::PushTransform(t) => {
1549 flush_all!(
1550 pass,
1551 &self.queue,
1552 self.streams,
1553 &self.rect_pipeline,
1554 &self.sdf_pipeline,
1555 &self.quad_pipeline,
1556 &self.path_gradient_pipeline,
1557 &self.shadow_pipeline,
1558 rect_batch,
1559 sdf_batch,
1560 quad_batch,
1561 path_gradient_batch,
1562 shadow_batch,
1563 self.atlas_texture,
1564 self.path_atlas_texture,
1565 quad_source,
1566 index_binding
1567 );
1568 quad_source = None;
1569 // See SetTransform: scale the translation column to
1570 // device pixels before composing.
1571 let device_t = Transform2D {
1572 m: [
1573 t.m[0],
1574 t.m[1],
1575 t.m[2],
1576 t.m[3],
1577 t.m[4] * scale_factor,
1578 t.m[5] * scale_factor,
1579 ],
1580 };
1581 let prev_top = transform_stack
1582 .last()
1583 .copied()
1584 .unwrap_or(Transform2D::IDENTITY);
1585 let new_top = device_t.then(&prev_top);
1586 transform_stack.push(new_top);
1587 current_transform = new_top;
1588 }
1589 teksilo_canvas::DrawCommand::PopTransform => {
1590 flush_all!(
1591 pass,
1592 &self.queue,
1593 self.streams,
1594 &self.rect_pipeline,
1595 &self.sdf_pipeline,
1596 &self.quad_pipeline,
1597 &self.path_gradient_pipeline,
1598 &self.shadow_pipeline,
1599 rect_batch,
1600 sdf_batch,
1601 quad_batch,
1602 path_gradient_batch,
1603 shadow_batch,
1604 self.atlas_texture,
1605 self.path_atlas_texture,
1606 quad_source,
1607 index_binding
1608 );
1609 quad_source = None;
1610 if transform_stack.len() > 1 {
1611 transform_stack.pop();
1612 }
1613 current_transform = transform_stack
1614 .last()
1615 .copied()
1616 .unwrap_or(Transform2D::IDENTITY);
1617 }
1618 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1619 | teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1620 // Unreachable — the inner-loop guard above
1621 // breaks before we enter the match for these.
1622 unreachable!("blur boundaries are handled at the segment level");
1623 }
1624 }
1625 cmd_idx += 1;
1626 }
1627
1628 // End-of-segment flush.
1629 flush_all!(
1630 pass,
1631 &self.queue,
1632 self.streams,
1633 &self.rect_pipeline,
1634 &self.sdf_pipeline,
1635 &self.quad_pipeline,
1636 &self.path_gradient_pipeline,
1637 &self.shadow_pipeline,
1638 rect_batch,
1639 sdf_batch,
1640 quad_batch,
1641 path_gradient_batch,
1642 shadow_batch,
1643 self.atlas_texture,
1644 self.path_atlas_texture,
1645 quad_source,
1646 index_binding
1647 );
1648 quad_source = None;
1649 } // pass dropped here, encoder borrow released
1650
1651 // Boundary handling. EOF, Begin, or End.
1652 if cmd_idx >= frame.draw_order.len() {
1653 break;
1654 }
1655 match &frame.draw_order[cmd_idx] {
1656 teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
1657 // Allocate intermediate sized to bounds × scale.
1658 let device_w = (bounds.width * scale_factor).ceil().max(1.0) as u32;
1659 let device_h = (bounds.height * scale_factor).ceil().max(1.0) as u32;
1660 let intermediate = self.blur_pool.acquire(&self.device, device_w, device_h);
1661 let (bucket_w, bucket_h) = self.blur_pool.dimensions(intermediate);
1662
1663 // Push a translation so the subtree renders at
1664 // (0, 0) of the intermediate. Device-pixel
1665 // translation since vertices arrive pre-scaled
1666 // (see SetTransform handler for the same trick).
1667 let translate = Transform2D {
1668 m: [
1669 1.0,
1670 0.0,
1671 0.0,
1672 1.0,
1673 -bounds.x * scale_factor,
1674 -bounds.y * scale_factor,
1675 ],
1676 };
1677 let prev_top = transform_stack
1678 .last()
1679 .copied()
1680 .unwrap_or(Transform2D::IDENTITY);
1681 let new_top = translate.then(&prev_top);
1682 transform_stack.push(new_top);
1683 current_transform = new_top;
1684
1685 target_stack.push(ActiveTarget {
1686 intermediate: Some(intermediate),
1687 viewport_w: bucket_w,
1688 viewport_h: bucket_h,
1689 opened: false,
1690 pending_composites: Vec::new(),
1691 blur_bounds: Some(*bounds),
1692 blur_radius_logical: Some(*radius),
1693 used_w: Some(device_w),
1694 used_h: Some(device_h),
1695 bucket_w: Some(bucket_w),
1696 bucket_h: Some(bucket_h),
1697 });
1698 }
1699 teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1700 let scope = target_stack
1701 .pop()
1702 .expect("EndBlurredSubtree without matching Begin");
1703 debug_assert!(
1704 scope.intermediate.is_some(),
1705 "End popped the surface (impossible if walker is balanced)"
1706 );
1707 let intermediate = scope
1708 .intermediate
1709 .expect("blur scope intermediate set in BeginBlurredSubtree");
1710 let bounds = scope
1711 .blur_bounds
1712 .expect("blur scope bounds set in BeginBlurredSubtree");
1713 let radius = scope
1714 .blur_radius_logical
1715 .expect("blur scope radius set in BeginBlurredSubtree");
1716 let used_w = scope
1717 .used_w
1718 .expect("blur scope used_w set in BeginBlurredSubtree");
1719 let used_h = scope
1720 .used_h
1721 .expect("blur scope used_h set in BeginBlurredSubtree");
1722 let bucket_w = scope
1723 .bucket_w
1724 .expect("blur scope bucket_w set in BeginBlurredSubtree");
1725 let bucket_h = scope
1726 .bucket_h
1727 .expect("blur scope bucket_h set in BeginBlurredSubtree");
1728
1729 // Pop the translation pushed in Begin.
1730 if transform_stack.len() > 1 {
1731 transform_stack.pop();
1732 }
1733 current_transform = transform_stack
1734 .last()
1735 .copied()
1736 .unwrap_or(Transform2D::IDENTITY);
1737
1738 // Run dual-Kawase. The chain begins its own
1739 // sub-passes against pool textures — the outer
1740 // segment's pass is already dropped.
1741 let blurred = run_kawase_chain(
1742 &self.device,
1743 &self.queue,
1744 &mut encoder,
1745 &mut self.blur_pool,
1746 &self.blur_pipelines,
1747 intermediate,
1748 used_w,
1749 used_h,
1750 bucket_w,
1751 bucket_h,
1752 radius * scale_factor,
1753 );
1754
1755 // Schedule a composite into the parent target's
1756 // next segment open.
1757 target_stack
1758 .last_mut()
1759 .expect("target_stack always has the surface target")
1760 .pending_composites
1761 .push(PendingComposite {
1762 blurred_texture: blurred.texture,
1763 used_w: blurred.used_w,
1764 used_h: blurred.used_h,
1765 bucket_w: blurred.bucket_w,
1766 bucket_h: blurred.bucket_h,
1767 bounds,
1768 });
1769 }
1770 _ => unreachable!("inner loop only breaks on Begin/End"),
1771 }
1772 cmd_idx += 1;
1773 }
1774
1775 debug_assert!(
1776 target_stack.len() == 1,
1777 "target_stack not balanced at EOF — unmatched Begin/End in walker output"
1778 );
1779 // The remaining surface target may still have a pending
1780 // composite (an outermost blur scope ending at end-of-frame
1781 // with no further commands). Drain it in one final pass.
1782 let final_composites = std::mem::take(
1783 &mut target_stack
1784 .last_mut()
1785 .expect("target_stack always has the surface target")
1786 .pending_composites,
1787 );
1788 if !final_composites.is_empty() {
1789 let surface = target_stack
1790 .last_mut()
1791 .expect("target_stack always has the surface target");
1792 let load_op = if surface.opened {
1793 wgpu::LoadOp::Load
1794 } else {
1795 wgpu::LoadOp::Clear(surface_clear_color)
1796 };
1797 surface.opened = true;
1798 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1799 label: Some("teksilo_final_composite_pass"),
1800 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1801 view,
1802 resolve_target: None,
1803 ops: wgpu::Operations {
1804 load: load_op,
1805 store: wgpu::StoreOp::Store,
1806 },
1807 depth_slice: None,
1808 })],
1809 depth_stencil_attachment: None,
1810 timestamp_writes: None,
1811 occlusion_query_set: None,
1812 multiview_mask: None,
1813 });
1814 for pc in &final_composites {
1815 composite_blur_quad(
1816 &self.device,
1817 &self.queue,
1818 &mut pass,
1819 &self.blur_pool,
1820 &self.quad_pipeline,
1821 &self.quad_bind_group_layout,
1822 &self.blur_composite_sampler,
1823 &self.streams.quad,
1824 index_binding,
1825 pc.blurred_texture,
1826 pc.used_w,
1827 pc.used_h,
1828 pc.bucket_w,
1829 pc.bucket_h,
1830 pc.bounds,
1831 scale_factor,
1832 viewport_width,
1833 viewport_height,
1834 );
1835 }
1836 } else if !target_stack
1837 .last()
1838 .expect("target_stack always has the surface target")
1839 .opened
1840 {
1841 // Empty frame — open one pass to apply the clear.
1842 let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1843 label: Some("teksilo_empty_clear_pass"),
1844 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1845 view,
1846 resolve_target: None,
1847 ops: wgpu::Operations {
1848 load: wgpu::LoadOp::Clear(surface_clear_color),
1849 store: wgpu::StoreOp::Store,
1850 },
1851 depth_slice: None,
1852 })],
1853 depth_stencil_attachment: None,
1854 timestamp_writes: None,
1855 occlusion_query_set: None,
1856 multiview_mask: None,
1857 });
1858 }
1859 }
1860
1861 self.queue.submit(std::iter::once(encoder.finish()));
1862 }
1863
1864 // draw_rect, draw_sdf, draw_quad, draw_shadow, draw_path_quad removed —
1865 // replaced by batched rendering in render().
1866
1867 #[allow(clippy::too_many_arguments)]
1868 fn draw_image(
1869 &self,
1870 pass: &mut wgpu::RenderPass,
1871 image: &teksilo_canvas::ImageQuad,
1872 scale_factor: f32,
1873 viewport_width: u32,
1874 viewport_height: u32,
1875 opacity: f32,
1876 transform: &Transform2D,
1877 index_binding: Option<(&wgpu::Buffer, u64, u64)>,
1878 ) {
1879 let bind_group = match self.image_manager.get_bind_group(&image.name) {
1880 Some(bg) => bg,
1881 None => return,
1882 };
1883
1884 let [x, y, w, h] = image.screen;
1885 let sx = x * scale_factor;
1886 let sy = y * scale_factor;
1887 let sw = w * scale_factor;
1888 let sh = h * scale_factor;
1889
1890 // Tintable mode: image is an alpha mask tinted with the given color (flag=0).
1891 // Full-color mode: image RGB used directly (flag=1, existing behavior).
1892 let (color, flags) = if let Some(tint) = image.tint {
1893 // Tint colors are sRGB-encoded (from teksilo_tokens::Color) — linearize
1894 // for the Rgba8UnormSrgb surface, same as all other vertex colors.
1895 (
1896 crate::vertex::srgb_to_linear_rgba([tint[0], tint[1], tint[2], tint[3] * opacity]),
1897 0,
1898 )
1899 } else {
1900 (
1901 [1.0, 1.0, 1.0, opacity],
1902 crate::vertex::QUAD_FLAG_COLOR_GLYPH,
1903 )
1904 };
1905
1906 let verts = [
1907 QuadVertex {
1908 position: [sx, sy],
1909 tex_coord: [0.0, 0.0],
1910 color,
1911 flags,
1912 _pad: 0,
1913 },
1914 QuadVertex {
1915 position: [sx + sw, sy],
1916 tex_coord: [1.0, 0.0],
1917 color,
1918 flags,
1919 _pad: 0,
1920 },
1921 QuadVertex {
1922 position: [sx + sw, sy + sh],
1923 tex_coord: [1.0, 1.0],
1924 color,
1925 flags,
1926 _pad: 0,
1927 },
1928 QuadVertex {
1929 position: [sx, sy + sh],
1930 tex_coord: [0.0, 1.0],
1931 color,
1932 flags,
1933 _pad: 0,
1934 },
1935 ];
1936
1937 let ndc_verts: [QuadVertex; 4] = std::array::from_fn(|i| {
1938 let v = verts[i];
1939 let tp = apply_transform_pixel(v.position, transform);
1940 QuadVertex {
1941 position: pixel_to_ndc(tp, viewport_width, viewport_height),
1942 ..v
1943 }
1944 });
1945
1946 // Reuse the persistent quad stream buffer instead of allocating
1947 // a fresh vertex buffer per image. Indices come from the shared
1948 // index stream populated at the top of `render()`.
1949 let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1950 let Some((vb, v_off, v_len)) = self.streams.quad.write(&self.queue, bytes) else {
1951 return;
1952 };
1953 let Some((ib, _, _)) = index_binding else {
1954 return;
1955 };
1956
1957 pass.set_pipeline(&self.quad_pipeline);
1958 pass.set_bind_group(0, bind_group, &[]);
1959 pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
1960 pass.set_index_buffer(ib.slice(0..24), wgpu::IndexFormat::Uint32);
1961 pass.draw_indexed(0..6, 0, 0..1);
1962 }
1963
1964 /// Upload path atlas texture data.
1965 fn upload_path_atlas(&mut self, width: u32, height: u32, pixels: Vec<u8>) {
1966 if width == 0 || height == 0 {
1967 return;
1968 }
1969
1970 let needs_recreate = self
1971 .path_atlas_texture
1972 .as_ref()
1973 .is_none_or(|t| t.width != width || t.height != height);
1974
1975 if needs_recreate {
1976 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1977 label: Some("path_atlas"),
1978 size: wgpu::Extent3d {
1979 width,
1980 height,
1981 depth_or_array_layers: 1,
1982 },
1983 mip_level_count: 1,
1984 sample_count: 1,
1985 dimension: wgpu::TextureDimension::D2,
1986 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1987 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1988 view_formats: &[],
1989 });
1990
1991 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1992 let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
1993 mag_filter: wgpu::FilterMode::Linear,
1994 min_filter: wgpu::FilterMode::Linear,
1995 ..Default::default()
1996 });
1997
1998 let bind_group_layout = self.quad_pipeline.get_bind_group_layout(0);
1999 let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
2000 label: Some("path_atlas_bind_group"),
2001 layout: &bind_group_layout,
2002 entries: &[
2003 wgpu::BindGroupEntry {
2004 binding: 0,
2005 resource: wgpu::BindingResource::TextureView(&view),
2006 },
2007 wgpu::BindGroupEntry {
2008 binding: 1,
2009 resource: wgpu::BindingResource::Sampler(&sampler),
2010 },
2011 ],
2012 });
2013
2014 self.path_atlas_texture = Some(AtlasTexture {
2015 texture,
2016 bind_group,
2017 width,
2018 height,
2019 });
2020 }
2021
2022 if let Some(atlas) = &self.path_atlas_texture {
2023 self.queue.write_texture(
2024 wgpu::TexelCopyTextureInfo {
2025 texture: &atlas.texture,
2026 mip_level: 0,
2027 origin: wgpu::Origin3d::ZERO,
2028 aspect: wgpu::TextureAspect::All,
2029 },
2030 &pixels,
2031 wgpu::TexelCopyBufferLayout {
2032 offset: 0,
2033 bytes_per_row: Some(width * 4),
2034 rows_per_image: Some(height),
2035 },
2036 wgpu::Extent3d {
2037 width,
2038 height,
2039 depth_or_array_layers: 1,
2040 },
2041 );
2042 }
2043 }
2044
2045 pub fn device(&self) -> &wgpu::Device {
2046 &self.device
2047 }
2048
2049 pub fn queue(&self) -> &wgpu::Queue {
2050 &self.queue
2051 }
2052
2053 /// Register an image for rendering by name.
2054 pub fn register_image(&mut self, name: &str, width: u32, height: u32, pixels: &[u8]) {
2055 let layout = self.quad_pipeline.get_bind_group_layout(0);
2056 self.image_manager.register_image(
2057 name,
2058 width,
2059 height,
2060 pixels,
2061 &self.device,
2062 &self.queue,
2063 &layout,
2064 );
2065 }
2066
2067 /// Remove a registered image.
2068 pub fn remove_image(&mut self, name: &str) {
2069 self.image_manager.remove(name);
2070 }
2071}
2072
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 u32s
2355/// already encode the standard quad index pattern, so we slice 24
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 u32s = `[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 per-frame upload in `render()` 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}