Skip to main content

wisp/render/
graphics_pipeline.rs

1//! Graphics primitive pipeline.
2//!
3//! Evolution: M0.12 (rect/rounded rect) → M0.13 (ellipse / line / stroke) →
4//! M0.14 (linear + radial gradient fills).
5//!
6//! All graphics primitives across all `Graphics` nodes batch into a single
7//! draw call. Stroked primitives emit a second outline instance. Lines are
8//! rendered as rotated thin rects.
9
10use std::collections::{HashMap, HashSet};
11
12use bytemuck::{Pod, Zeroable};
13use glam::{Mat4, Vec2};
14use wgpu::util::DeviceExt;
15
16use crate::application::Application;
17use crate::blend::BlendMode;
18use crate::color::Color;
19use crate::render::blend_pipeline::BlendPipelineMap;
20use crate::render::scene_walk::walk_visible_subtree;
21use crate::scene::graphics::{Fill, Primitive, Stroke};
22use crate::scene::{Node, NodeId, Stage};
23
24const KIND_RECT: u32 = 0;
25const KIND_ELLIPSE: u32 = 1;
26const KIND_ANNULAR_SECTOR: u32 = 2;
27const MODE_FILL: u32 = 0;
28const MODE_OUTLINE: u32 = 1;
29const FILL_SOLID: u32 = 0;
30const FILL_LINEAR: u32 = 1;
31const FILL_RADIAL: u32 = 2;
32
33#[repr(C)]
34#[derive(Clone, Copy, Pod, Zeroable)]
35pub(crate) struct GraphicsInstance {
36    pub model: [[f32; 4]; 4],
37    pub color: [f32; 4],
38    pub color_b: [f32; 4],
39    pub half_extents: [f32; 2],
40    pub radius: f32,
41    pub stroke_width: f32,
42    pub grad_a: [f32; 2],
43    pub grad_b: [f32; 2],
44    /// Packed `[kind, mode, fill_kind, _padding]`. Bundled into
45    /// one Uint32x4 attribute so the subsequent `arc_data`
46    /// Float32x4 falls on a 16-byte boundary — `vertex_attr_array!`
47    /// computes offsets sequentially without honouring struct
48    /// padding, so a packed quad here matches the `repr(C)`
49    /// layout exactly.
50    pub kind_pack: [u32; 4],
51    /// `[r_inner, r_outer, mid_angle, half_angle]` for annular
52    /// sectors. Zeroed (and ignored) for other primitive kinds.
53    pub arc_data: [f32; 4],
54}
55
56const ATTR_LAYOUT: [wgpu::VertexAttribute; 13] = wgpu::vertex_attr_array![
57    0  => Float32x4,
58    1  => Float32x4,
59    2  => Float32x4,
60    3  => Float32x4,
61    4  => Float32x4,
62    5  => Float32x4,
63    6  => Float32x2,
64    7  => Float32,
65    8  => Float32,
66    9  => Float32x2,
67    10 => Float32x2,
68    11 => Uint32x4,
69    12 => Float32x4,
70];
71
72/// One vertex of a polygon's fan triangulation. Position is in
73/// clip space — the world matrix is baked CPU-side during the
74/// scene walk, since polygon vertex counts are variable and don't
75/// fit the per-instance model the SDF primitives use.
76#[repr(C)]
77#[derive(Clone, Copy, Pod, Zeroable)]
78pub(crate) struct PolygonVertex {
79    pub position: [f32; 2],
80    pub color: [f32; 4],
81}
82
83const POLYGON_ATTR_LAYOUT: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
84    0 => Float32x2,
85    1 => Float32x4,
86];
87
88pub(crate) struct GraphicsPipeline {
89    pipelines: BlendPipelineMap,
90    polygon_pipelines: BlendPipelineMap,
91}
92
93impl GraphicsPipeline {
94    pub(crate) fn new(app: &Application, output_format: wgpu::TextureFormat) -> Self {
95        let device = app.device();
96        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
97            label: Some("wisp::graphics_solid"),
98            source: wgpu::ShaderSource::Wgsl(
99                include_str!("../../shaders/graphics_solid.wgsl").into(),
100            ),
101        });
102
103        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
104            label: Some("wisp::graphics pipeline layout"),
105            bind_group_layouts: &[],
106            push_constant_ranges: &[],
107        });
108
109        let pipelines = BlendPipelineMap::new(|blend| {
110            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
111                label: Some("wisp::graphics pipeline"),
112                layout: Some(&layout),
113                vertex: wgpu::VertexState {
114                    module: &shader,
115                    entry_point: Some("main_vs"),
116                    buffers: &[wgpu::VertexBufferLayout {
117                        array_stride: std::mem::size_of::<GraphicsInstance>()
118                            as wgpu::BufferAddress,
119                        step_mode: wgpu::VertexStepMode::Instance,
120                        attributes: &ATTR_LAYOUT,
121                    }],
122                    compilation_options: wgpu::PipelineCompilationOptions::default(),
123                },
124                fragment: Some(wgpu::FragmentState {
125                    module: &shader,
126                    entry_point: Some("main_fs"),
127                    targets: &[Some(wgpu::ColorTargetState {
128                        format: output_format,
129                        blend: Some(blend),
130                        write_mask: wgpu::ColorWrites::ALL,
131                    })],
132                    compilation_options: wgpu::PipelineCompilationOptions::default(),
133                }),
134                primitive: wgpu::PrimitiveState::default(),
135                depth_stencil: None,
136                multisample: wgpu::MultisampleState::default(),
137                multiview: None,
138                cache: None,
139            })
140        });
141
142        // Polygon (triangle-list) sub-pipeline. Separate WGSL +
143        // VertexBufferLayout because polygons have variable vertex
144        // counts and can't fit the instanced-quad model the SDF
145        // path uses.
146        let polygon_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
147            label: Some("wisp::graphics_polygon"),
148            source: wgpu::ShaderSource::Wgsl(
149                include_str!("../../shaders/graphics_polygon.wgsl").into(),
150            ),
151        });
152        let polygon_pipelines = BlendPipelineMap::new(|blend| {
153            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
154                label: Some("wisp::graphics polygon pipeline"),
155                layout: Some(&layout),
156                vertex: wgpu::VertexState {
157                    module: &polygon_shader,
158                    entry_point: Some("main_vs"),
159                    buffers: &[wgpu::VertexBufferLayout {
160                        array_stride: std::mem::size_of::<PolygonVertex>() as wgpu::BufferAddress,
161                        step_mode: wgpu::VertexStepMode::Vertex,
162                        attributes: &POLYGON_ATTR_LAYOUT,
163                    }],
164                    compilation_options: wgpu::PipelineCompilationOptions::default(),
165                },
166                fragment: Some(wgpu::FragmentState {
167                    module: &polygon_shader,
168                    entry_point: Some("main_fs"),
169                    targets: &[Some(wgpu::ColorTargetState {
170                        format: output_format,
171                        blend: Some(blend),
172                        write_mask: wgpu::ColorWrites::ALL,
173                    })],
174                    compilation_options: wgpu::PipelineCompilationOptions::default(),
175                }),
176                primitive: wgpu::PrimitiveState::default(),
177                depth_stencil: None,
178                multisample: wgpu::MultisampleState::default(),
179                multiview: None,
180                cache: None,
181            })
182        });
183
184        Self {
185            pipelines,
186            polygon_pipelines,
187        }
188    }
189
190    pub(crate) fn draw_stage(
191        &self,
192        app: &Application,
193        pass: &mut wgpu::RenderPass<'_>,
194        stage: &Stage,
195    ) -> (u32, u32) {
196        self.draw_subtree(app, pass, stage, stage.root(), &HashSet::new())
197    }
198
199    /// Subtree variant — see `SpritePipeline::draw_subtree`.
200    pub(crate) fn draw_subtree(
201        &self,
202        app: &Application,
203        pass: &mut wgpu::RenderPass<'_>,
204        stage: &Stage,
205        start: NodeId,
206        exclude: &HashSet<NodeId>,
207    ) -> (u32, u32) {
208        let CollectedGraphics {
209            instance_groups,
210            polygon_groups,
211            logical_count,
212        } = collect_graphics(stage, start, exclude);
213        let mut draw_calls = 0u32;
214
215        // SDF instance pass (rect / rounded rect / ellipse / line /
216        // annular sector). Issued first so polygons composite on
217        // top in scene-tree order, matching the existing primitive
218        // ordering semantics inside a single Graphics node.
219        for (mode, instances) in instance_groups {
220            if instances.is_empty() {
221                continue;
222            }
223            let count = u32::try_from(instances.len()).expect("instance count fits in u32");
224            let buffer = app
225                .device()
226                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
227                    label: Some("wisp::graphics instances"),
228                    contents: bytemuck::cast_slice(&instances),
229                    usage: wgpu::BufferUsages::VERTEX,
230                });
231
232            pass.set_pipeline(self.pipelines.get(mode));
233            pass.set_vertex_buffer(0, buffer.slice(..));
234            pass.draw(0..6, 0..count);
235            draw_calls += 1;
236        }
237
238        // Polygon triangle-list pass.
239        for (mode, vertices) in polygon_groups {
240            if vertices.is_empty() {
241                continue;
242            }
243            let vertex_count =
244                u32::try_from(vertices.len()).expect("polygon vertex count fits in u32");
245            let buffer = app
246                .device()
247                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
248                    label: Some("wisp::graphics polygon vertices"),
249                    contents: bytemuck::cast_slice(&vertices),
250                    usage: wgpu::BufferUsages::VERTEX,
251                });
252            pass.set_pipeline(self.polygon_pipelines.get(mode));
253            pass.set_vertex_buffer(0, buffer.slice(..));
254            pass.draw(0..vertex_count, 0..1);
255            draw_calls += 1;
256        }
257
258        (draw_calls, logical_count)
259    }
260}
261
262struct CollectedGraphics {
263    instance_groups: Vec<(BlendMode, Vec<GraphicsInstance>)>,
264    polygon_groups: Vec<(BlendMode, Vec<PolygonVertex>)>,
265    logical_count: u32,
266}
267
268/// Walk the scene and bucket every primitive a `Graphics` node
269/// emits, separated into:
270/// * SDF instances (rect / rounded rect / ellipse / line / arc),
271/// * polygon triangles (variable-vertex-count primitives that go
272///   through the dedicated `graphics_polygon.wgsl` path).
273///
274/// Both buckets group by blend mode in encounter order so each
275/// batch binds its matching pipeline.
276fn collect_graphics(stage: &Stage, start: NodeId, exclude: &HashSet<NodeId>) -> CollectedGraphics {
277    let mut instance_grouped: HashMap<BlendMode, Vec<GraphicsInstance>> = HashMap::new();
278    let mut polygon_grouped: HashMap<BlendMode, Vec<PolygonVertex>> = HashMap::new();
279    let mut instance_order: Vec<BlendMode> = Vec::new();
280    let mut polygon_order: Vec<BlendMode> = Vec::new();
281    let mut logical = 0u32;
282    walk_visible_subtree(stage, start, exclude, |_id, node, world| {
283        let container = node.container();
284        if let Node::Graphics(graphics) = node {
285            let mode = container.blend_mode;
286            for primitive in &graphics.primitives {
287                logical = logical.saturating_add(1);
288                if matches!(primitive, Primitive::Polygon { .. }) {
289                    let bucket = polygon_grouped.entry(mode).or_insert_with(|| {
290                        polygon_order.push(mode);
291                        Vec::new()
292                    });
293                    emit_polygon(primitive, world, container.alpha, bucket);
294                } else {
295                    let bucket = instance_grouped.entry(mode).or_insert_with(|| {
296                        instance_order.push(mode);
297                        Vec::new()
298                    });
299                    emit_primitive(primitive, world, container.alpha, bucket);
300                }
301            }
302        }
303    });
304    let instance_groups = instance_order
305        .into_iter()
306        .filter_map(|m| instance_grouped.remove(&m).map(|v| (m, v)))
307        .collect();
308    let polygon_groups = polygon_order
309        .into_iter()
310        .filter_map(|m| polygon_grouped.remove(&m).map(|v| (m, v)))
311        .collect();
312    CollectedGraphics {
313        instance_groups,
314        polygon_groups,
315        logical_count: logical,
316    }
317}
318
319#[allow(
320    clippy::too_many_lines,
321    reason = "single dispatch table over Primitive variants — splitting per variant would add indirection without clarity"
322)]
323fn emit_primitive(p: &Primitive, world: Mat4, parent_alpha: f32, out: &mut Vec<GraphicsInstance>) {
324    match *p {
325        Primitive::RoundedRect {
326            rect,
327            radius,
328            fill,
329            stroke,
330        } => {
331            let half = Vec2::new(rect.size.x * 0.5, rect.size.y * 0.5);
332            let center = rect.min + half;
333            let model = world * Mat4::from_translation(glam::Vec3::new(center.x, center.y, 0.0));
334            out.push(rect_instance(
335                model,
336                half,
337                radius,
338                fill,
339                parent_alpha,
340                MODE_FILL,
341                0.0,
342            ));
343            if let Some(s) = stroke {
344                out.push(rect_instance(
345                    model,
346                    half,
347                    radius,
348                    Fill::Solid(s.color),
349                    parent_alpha,
350                    MODE_OUTLINE,
351                    s.width,
352                ));
353            }
354        }
355        Primitive::Ellipse {
356            center,
357            radii,
358            fill,
359            stroke,
360        } => {
361            let model = world * Mat4::from_translation(glam::Vec3::new(center.x, center.y, 0.0));
362            out.push(ellipse_instance(
363                model,
364                radii,
365                fill,
366                parent_alpha,
367                MODE_FILL,
368                0.0,
369            ));
370            if let Some(s) = stroke {
371                out.push(ellipse_instance(
372                    model,
373                    radii,
374                    Fill::Solid(s.color),
375                    parent_alpha,
376                    MODE_OUTLINE,
377                    s.width,
378                ));
379            }
380        }
381        Primitive::Line {
382            from,
383            to,
384            width,
385            fill,
386        } => {
387            let delta = to - from;
388            let length = delta.length();
389            if length < f32::EPSILON {
390                return;
391            }
392            let angle = delta.y.atan2(delta.x);
393            let center = (from + to) * 0.5;
394            let translate = Mat4::from_translation(glam::Vec3::new(center.x, center.y, 0.0));
395            let rotate = Mat4::from_rotation_z(angle);
396            let model = world * translate * rotate;
397            let half = Vec2::new(length * 0.5, width * 0.5);
398            out.push(rect_instance(
399                model,
400                half,
401                0.0,
402                fill,
403                parent_alpha,
404                MODE_FILL,
405                0.0,
406            ));
407        }
408        Primitive::Polygon { .. } => {
409            // Handled by `emit_polygon` — separate triangle-list
410            // path. Reaching here would mean the dispatcher
411            // misrouted; bail rather than emit a malformed
412            // instance.
413        }
414        Primitive::AnnularSector {
415            center,
416            r_inner,
417            r_outer,
418            start_angle,
419            end_angle,
420            fill,
421            stroke,
422        } => {
423            // Quad covers the AABB around the annular sector
424            // centred at `center`. Outer-radius bound is the
425            // simple safe choice (slightly oversized when the
426            // angular span doesn't wrap a full half-circle, but
427            // never undersized — no clipping).
428            let r_inner = r_inner.max(0.0);
429            let r_outer = r_outer.max(r_inner);
430            let span = (end_angle - start_angle).clamp(0.0, std::f32::consts::TAU);
431            let mid_angle = start_angle + span * 0.5;
432            let half_angle = span * 0.5;
433            let model = world * Mat4::from_translation(glam::Vec3::new(center.x, center.y, 0.0));
434            let half = Vec2::new(r_outer, r_outer);
435            out.push(annular_sector_instance(
436                model,
437                half,
438                r_inner,
439                r_outer,
440                mid_angle,
441                half_angle,
442                fill,
443                parent_alpha,
444                MODE_FILL,
445                0.0,
446            ));
447            if let Some(s) = stroke {
448                out.push(annular_sector_instance(
449                    model,
450                    half,
451                    r_inner,
452                    r_outer,
453                    mid_angle,
454                    half_angle,
455                    Fill::Solid(s.color),
456                    parent_alpha,
457                    MODE_OUTLINE,
458                    s.width,
459                ));
460            }
461        }
462    }
463}
464
465fn rect_instance(
466    model: Mat4,
467    half: Vec2,
468    radius: f32,
469    fill: Fill,
470    parent_alpha: f32,
471    mode: u32,
472    stroke_width: f32,
473) -> GraphicsInstance {
474    let resolved = resolve_fill(fill, parent_alpha);
475    GraphicsInstance {
476        model: model.to_cols_array_2d(),
477        color: resolved.color,
478        color_b: resolved.color_b,
479        half_extents: [half.x, half.y],
480        radius,
481        stroke_width,
482        grad_a: resolved.grad_a,
483        grad_b: resolved.grad_b,
484        kind_pack: [KIND_RECT, mode, resolved.fill_kind, 0],
485        arc_data: [0.0; 4],
486    }
487}
488
489fn ellipse_instance(
490    model: Mat4,
491    radii: Vec2,
492    fill: Fill,
493    parent_alpha: f32,
494    mode: u32,
495    stroke_width: f32,
496) -> GraphicsInstance {
497    let resolved = resolve_fill(fill, parent_alpha);
498    GraphicsInstance {
499        model: model.to_cols_array_2d(),
500        color: resolved.color,
501        color_b: resolved.color_b,
502        half_extents: [radii.x, radii.y],
503        radius: 0.0,
504        stroke_width,
505        grad_a: resolved.grad_a,
506        grad_b: resolved.grad_b,
507        kind_pack: [KIND_ELLIPSE, mode, resolved.fill_kind, 0],
508        arc_data: [0.0; 4],
509    }
510}
511
512/// Fan-triangulate a convex polygon and bake each vertex into
513/// clip space via `world`. Non-convex polygons produce visible
514/// overlap — that's the documented v1 limitation.
515fn emit_polygon(p: &Primitive, world: Mat4, parent_alpha: f32, out: &mut Vec<PolygonVertex>) {
516    let Primitive::Polygon { vertices, fill } = p else {
517        return;
518    };
519    if vertices.len() < 3 {
520        return;
521    }
522    let color = apply_alpha(
523        match *fill {
524            Fill::Solid(c) => c,
525            // Gradients on polygons are deferred — flatten to the
526            // first colour stop so the primitive still renders.
527            Fill::LinearGradient { color_a, .. } | Fill::RadialGradient { color_a, .. } => color_a,
528        },
529        parent_alpha,
530    );
531    let to_clip = |v: Vec2| -> [f32; 2] {
532        let clip = world * glam::Vec4::new(v.x, v.y, 0.0, 1.0);
533        [clip.x, clip.y]
534    };
535    // Fan from vertex 0: (v0, v_i, v_{i+1}) for i in 1..n-1.
536    let v0 = to_clip(vertices[0]);
537    for i in 1..vertices.len() - 1 {
538        let vi = to_clip(vertices[i]);
539        let vj = to_clip(vertices[i + 1]);
540        out.push(PolygonVertex {
541            position: v0,
542            color,
543        });
544        out.push(PolygonVertex {
545            position: vi,
546            color,
547        });
548        out.push(PolygonVertex {
549            position: vj,
550            color,
551        });
552    }
553}
554
555#[allow(
556    clippy::too_many_arguments,
557    reason = "annular sector emission inherits the same flat parameter style as rect/ellipse helpers"
558)]
559fn annular_sector_instance(
560    model: Mat4,
561    half: Vec2,
562    r_inner: f32,
563    r_outer: f32,
564    mid_angle: f32,
565    half_angle: f32,
566    fill: Fill,
567    parent_alpha: f32,
568    mode: u32,
569    stroke_width: f32,
570) -> GraphicsInstance {
571    let resolved = resolve_fill(fill, parent_alpha);
572    GraphicsInstance {
573        model: model.to_cols_array_2d(),
574        color: resolved.color,
575        color_b: resolved.color_b,
576        half_extents: [half.x, half.y],
577        radius: 0.0,
578        stroke_width,
579        grad_a: resolved.grad_a,
580        grad_b: resolved.grad_b,
581        kind_pack: [KIND_ANNULAR_SECTOR, mode, resolved.fill_kind, 0],
582        arc_data: [r_inner, r_outer, mid_angle, half_angle],
583    }
584}
585
586struct ResolvedFill {
587    fill_kind: u32,
588    color: [f32; 4],
589    color_b: [f32; 4],
590    grad_a: [f32; 2],
591    grad_b: [f32; 2],
592}
593
594fn resolve_fill(fill: Fill, parent_alpha: f32) -> ResolvedFill {
595    match fill {
596        Fill::Solid(c) => {
597            let arr = apply_alpha(c, parent_alpha);
598            ResolvedFill {
599                fill_kind: FILL_SOLID,
600                color: arr,
601                color_b: arr,
602                grad_a: [0.0, 0.0],
603                grad_b: [0.0, 0.0],
604            }
605        }
606        Fill::LinearGradient {
607            start,
608            end,
609            color_a,
610            color_b,
611        } => ResolvedFill {
612            fill_kind: FILL_LINEAR,
613            color: apply_alpha(color_a, parent_alpha),
614            color_b: apply_alpha(color_b, parent_alpha),
615            grad_a: [start.x, start.y],
616            grad_b: [end.x, end.y],
617        },
618        Fill::RadialGradient {
619            center,
620            radius,
621            color_a,
622            color_b,
623        } => ResolvedFill {
624            fill_kind: FILL_RADIAL,
625            color: apply_alpha(color_a, parent_alpha),
626            color_b: apply_alpha(color_b, parent_alpha),
627            grad_a: [center.x, center.y],
628            grad_b: [radius, 0.0],
629        },
630    }
631}
632
633fn apply_alpha(c: Color, parent_alpha: f32) -> [f32; 4] {
634    [c.r, c.g, c.b, c.a * parent_alpha]
635}
636
637#[allow(dead_code, reason = "re-exported to keep collect_instances readable")]
638const _: Option<Stroke> = None;