diff --git a/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.dims.json b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.dims.json new file mode 100644 index 0000000..a250c44 --- /dev/null +++ b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.dims.json @@ -0,0 +1,6 @@ +{ + "width": 210, + "height": 215, + "origin_x": 5, + "origin_y": 10 +} \ No newline at end of file diff --git a/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.png b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.png new file mode 100644 index 0000000..f0177ae Binary files /dev/null and b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.png differ diff --git a/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.sg.json b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.sg.json new file mode 100644 index 0000000..16dc004 --- /dev/null +++ b/sg2d-vega-test-data/vega-scenegraphs/rule/wide_transparent_caps.sg.json @@ -0,0 +1,60 @@ +{ + "marktype": "group", + "name": "root", + "role": "frame", + "interactive": true, + "clip": false, + "items": [ + { + "items": [ + { + "marktype": "rule", + "name": "marks", + "role": "mark", + "interactive": true, + "clip": false, + "items": [ + { + "x": 140, + "y": 15, + "opacity": 0.5, + "stroke": "orange", + "strokeWidth": 12, + "strokeCap": "butt", + "x2": 60, + "y2": 180 + }, + { + "x": 20, + "y": 15, + "opacity": 0.5, + "stroke": "blue", + "strokeWidth": 16, + "strokeCap": "square", + "x2": 120, + "y2": 140 + }, + { + "x": 50, + "y": 15, + "opacity": 0.5, + "stroke": "green", + "strokeWidth": 20, + "strokeCap": "round", + "x2": 120, + "y2": 180 + } + ], + "zindex": 0 + } + ], + "x": 0, + "y": 0, + "width": 200, + "height": 200, + "fill": "transparent", + "stroke": "transparent" + } + ], + "zindex": 0 +} \ No newline at end of file diff --git a/sg2d-vega-test-data/vega-specs/rule/wide_transparent_butt.vg.json b/sg2d-vega-test-data/vega-specs/rule/wide_transparent_caps.vg.json similarity index 74% rename from sg2d-vega-test-data/vega-specs/rule/wide_transparent_butt.vg.json rename to sg2d-vega-test-data/vega-specs/rule/wide_transparent_caps.vg.json index cdfef92..307bb36 100644 --- a/sg2d-vega-test-data/vega-specs/rule/wide_transparent_butt.vg.json +++ b/sg2d-vega-test-data/vega-specs/rule/wide_transparent_caps.vg.json @@ -10,9 +10,9 @@ "data": [{ "name": "source_0", "values": [ - {"x": 140, "x2": 60, "y": 15, "y2": 180, "fill": "orange"}, - {"x": 20, "x2": 120, "y": 15, "y2": 140, "fill": "blue"}, - {"x": 50, "x2": 120, "y": 15, "y2": 180, "fill": "green"} + {"x": 140, "x2": 60, "y": 15, "y2": 180, "fill": "orange", "cap": "butt", "width": 12}, + {"x": 20, "x2": 120, "y": 15, "y2": 140, "fill": "blue", "cap": "square", "width": 16}, + {"x": 50, "x2": 120, "y": 15, "y2": 180, "fill": "green", "cap": "round", "width": 20} ] }], "marks": [ @@ -23,14 +23,14 @@ "from": {"data": "source_0"}, "encode": { "update": { - "strokeWidth": {"value": 16}, + "strokeWidth": {"field": "width"}, "stroke": {"field": "fill"}, "x": {"field": "x" }, "x2": {"field": "x2"}, "y": {"field": "y"}, "y2": {"field": "y2"}, "opacity": {"value": 0.5}, - "strokeCap": {"value": "butt"} + "strokeCap": {"field": "cap"} } } } diff --git a/sg2d-wgpu/src/marks/rule.rs b/sg2d-wgpu/src/marks/rule.rs index 29de72b..60fc93e 100644 --- a/sg2d-wgpu/src/marks/rule.rs +++ b/sg2d-wgpu/src/marks/rule.rs @@ -1,6 +1,7 @@ use crate::marks::mark::MarkShader; use itertools::izip; use sg2d::marks::rule::RuleMark; +use sg2d::value::StrokeCap; use wgpu::VertexBufferLayout; #[repr(C)] @@ -23,6 +24,10 @@ impl RuleVertex { } } +const STROKE_CAP_BUTT: u32 = 0; +const STROKE_CAP_SQUARE: u32 = 1; +const STROKE_CAP_ROUND: u32 = 2; + #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct RuleInstance { @@ -32,15 +37,17 @@ pub struct RuleInstance { pub y1: f32, pub stroke: [f32; 4], pub stroke_width: f32, + pub stroke_cap: u32, } -const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![ +const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![ 1 => Float32, // x0 2 => Float32, // y0 3 => Float32, // x1 4 => Float32, // y1 5 => Float32x4, // stroke 6 => Float32, // stroke_width + 7 => Uint32, // stroke_cap_type ]; impl RuleInstance { @@ -52,14 +59,20 @@ impl RuleInstance { mark.y1_iter(), mark.stroke_iter(), mark.stroke_width_iter(), + mark.stroke_cap_iter(), ) - .map(|(x0, y0, x1, y1, stroke, stroke_width)| RuleInstance { + .map(|(x0, y0, x1, y1, stroke, stroke_width, cap)| RuleInstance { x0: *x0, y0: *y0, x1: *x1, y1: *y1, stroke: *stroke, stroke_width: *stroke_width, + stroke_cap: match cap { + StrokeCap::Butt => STROKE_CAP_BUTT, + StrokeCap::Square => STROKE_CAP_SQUARE, + StrokeCap::Round => STROKE_CAP_ROUND, + }, }) } } diff --git a/sg2d-wgpu/src/marks/rule.wgsl b/sg2d-wgpu/src/marks/rule.wgsl index 1c6156a..051e444 100644 --- a/sg2d-wgpu/src/marks/rule.wgsl +++ b/sg2d-wgpu/src/marks/rule.wgsl @@ -20,15 +20,24 @@ struct InstanceInput { @location(4) y1: f32, @location(5) stroke: vec4, @location(6) stroke_width: f32, + @location(7) stroke_cap: u32, }; struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) color: vec4, + @location(1) p0: vec2, + @location(2) p1: vec2, + @location(3) radius: f32, + @location(4) stroke_half_width: f32, }; const PI = 3.14159265359; +const STROKE_CAP_BUTT: u32 = 0u; +const STROKE_CAP_SQUARE: u32 = 1u; +const STROKE_CAP_ROUND: u32 = 2u; + @vertex fn vs_main( model: VertexInput, @@ -37,11 +46,29 @@ fn vs_main( var out: VertexOutput; out.color = instance.stroke; - let p0 = vec2(instance.x0, instance.y0); - let p1 = vec2(instance.x1, instance.y1); + var width: f32 = instance.stroke_width; + var p0: vec2 = vec2(instance.x0, instance.y0); + var p1: vec2 = vec2(instance.x1, instance.y1); let mid = (p0 + p1) / 2.0; - let len = distance(p0, p1); - let width = instance.stroke_width; + var len: f32 = distance(p0, p1); + if (instance.stroke_cap == STROKE_CAP_ROUND) { + // extend length, but leave p0 and p1 at the center of the circleular end caps + len += width; + } else if (instance.stroke_cap == STROKE_CAP_SQUARE) { + // Extend length and move p0 and p1 to the outer edge of the square + len += width; + let p0p1_norm = normalize(p1 - p0); + p0 -= p0p1_norm * (width / 2.0); + p1 += p0p1_norm * (width / 2.0); + } + + let should_anitalias = instance.stroke_cap == STROKE_CAP_ROUND || (instance.x0 != instance.x1 && instance.y0 != instance.y1); + if (should_anitalias) { + // Add anti-aliasing buffer for rules with rounded caps and all diagonal rules. + // Non-round rules that are vertical or horizontal don't get anti-aliasing. + len += chart_uniforms.scale; + width += chart_uniforms.scale; + } let normed = normalize(p1 - p0); let angle = (PI / 2.0) + atan2(normed[1], normed[0]); @@ -52,11 +79,69 @@ fn vs_main( let y = 2.0 * (rot_pos[1] + (chart_uniforms.size[1] - mid[1])) / chart_uniforms.size[1] - 1.0; out.clip_position = vec4(x, y, 0.0, 1.0); + + out.p0 = p0 * chart_uniforms.scale; + out.p1 = p1 * chart_uniforms.scale; + + out.stroke_half_width = instance.stroke_width * chart_uniforms.scale / 2.0; + if (instance.stroke_cap == STROKE_CAP_ROUND) { + out.radius = instance.stroke_width * chart_uniforms.scale / 2.0; + } else { + out.radius = 0.0; + } + return out; } // Fragment shader @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return in.color; + let should_antialias = in.radius > 0.0 || (in.p0[0] != in.p1[0] && in.p0[1] != in.p1[1]); + if (!should_antialias) { + // This is a butt or square cap and fully vertical or horizontal + // vertex boundary matches desired rule area and we don't need to do any + // anti-aliasing. + return in.color; + } + + let frag_pos = vec2(in.clip_position[0], in.clip_position[1]); + let relative_frag_pos = frag_pos - in.p0; + + let relative_p1 = in.p1 - in.p0; + let relative_p0 = vec2(0.0, 0.0); + + let len = length(relative_p1); + let projected_frag_dist = dot(relative_frag_pos, relative_p1) / length(relative_p1); + let perpendicular_frag_pos = relative_frag_pos - normalize(relative_p1) * projected_frag_dist; + + // Compute fragment distance for anit-aliasing + var dist: f32 = 0.0; + if (in.radius > 0.0) { + // rounded cap + if (projected_frag_dist < 0.0) { + // distance to p0 + dist = distance(relative_frag_pos, relative_p0); + } else if (projected_frag_dist > len) { + // distance to p1 + dist = distance(relative_frag_pos, relative_p1); + } else { + // distance to line connecting p0 and p1 + dist = length(perpendicular_frag_pos); + } + } else { + // rule square or butt cap on a diagonal + if (projected_frag_dist < in.stroke_half_width) { + dist = max(-projected_frag_dist + in.stroke_half_width, length(perpendicular_frag_pos)); + } else if (projected_frag_dist > len - in.stroke_half_width) { + dist = max(projected_frag_dist - len + in.stroke_half_width, length(perpendicular_frag_pos)); + } else { + dist = length(perpendicular_frag_pos); + } + } + + let buffer = chart_uniforms.scale / 2.0; + let alpha_factor = 1.0 - smoothstep(in.stroke_half_width - buffer, in.stroke_half_width + buffer, dist); + var adjusted_color = in.color; + adjusted_color[3] *= alpha_factor; + return adjusted_color; } diff --git a/sg2d-wgpu/tests/test_image_baselines.rs b/sg2d-wgpu/tests/test_image_baselines.rs index a110937..0329fb8 100644 --- a/sg2d-wgpu/tests/test_image_baselines.rs +++ b/sg2d-wgpu/tests/test_image_baselines.rs @@ -13,35 +13,35 @@ mod test_image_baselines { category, spec_name, tolerance, - // case("rect", "stacked_bar", 0.001), - // case("rect", "heatmap", 0.006), - // case("symbol", "binned_scatter_diamonds", 0.001), - // case("symbol", "binned_scatter_square", 0.001), - // case("symbol", "binned_scatter_triangle-down", 0.001), - // case("symbol", "binned_scatter_triangle-up", 0.001), - // case("symbol", "binned_scatter_triangle-left", 0.001), - // case("symbol", "binned_scatter_triangle-right", 0.001), - // case("symbol", "binned_scatter_triangle", 0.001), - // case("symbol", "binned_scatter_wedge", 0.001), - // case("symbol", "binned_scatter_arrow", 0.001), - // case("symbol", "binned_scatter_cross", 0.001), - // case("symbol", "binned_scatter_circle", 0.001), - // case("symbol", "binned_scatter_path", 0.001), - // case("symbol", "binned_scatter_path_star", 0.001), - // case("symbol", "binned_scatter_cross_stroke", 0.001), - // case("symbol", "binned_scatter_circle_stroke", 0.001), - // case("symbol", "binned_scatter_circle_stroke_no_fill", 0.001), - // case("symbol", "binned_scatter_path_star_stroke_no_fill", 0.001), - // case("symbol", "scatter_transparent_stroke", 0.001), - // case("symbol", "scatter_transparent_stroke_star", 0.006), - // case("symbol", "wind_vector", 0.0015), - // case("symbol", "wedge_angle", 0.001), - // case("symbol", "wedge_stroke_angle", 0.001), - // case("symbol", "zindex_circles", 0.001), - // case("symbol", "mixed_symbols", 0.001), - // case("rule", "wide_rule_axes", 0.0001), - case("rule", "wide_transparent_butt", 0.0001), - // case("text", "bar_axis_labels", 0.025) + case("rect", "stacked_bar", 0.001), + case("rect", "heatmap", 0.006), + case("symbol", "binned_scatter_diamonds", 0.001), + case("symbol", "binned_scatter_square", 0.001), + case("symbol", "binned_scatter_triangle-down", 0.001), + case("symbol", "binned_scatter_triangle-up", 0.001), + case("symbol", "binned_scatter_triangle-left", 0.001), + case("symbol", "binned_scatter_triangle-right", 0.001), + case("symbol", "binned_scatter_triangle", 0.001), + case("symbol", "binned_scatter_wedge", 0.001), + case("symbol", "binned_scatter_arrow", 0.001), + case("symbol", "binned_scatter_cross", 0.001), + case("symbol", "binned_scatter_circle", 0.001), + case("symbol", "binned_scatter_path", 0.001), + case("symbol", "binned_scatter_path_star", 0.001), + case("symbol", "binned_scatter_cross_stroke", 0.001), + case("symbol", "binned_scatter_circle_stroke", 0.001), + case("symbol", "binned_scatter_circle_stroke_no_fill", 0.001), + case("symbol", "binned_scatter_path_star_stroke_no_fill", 0.001), + case("symbol", "scatter_transparent_stroke", 0.001), + case("symbol", "scatter_transparent_stroke_star", 0.006), + case("symbol", "wind_vector", 0.0015), + case("symbol", "wedge_angle", 0.001), + case("symbol", "wedge_stroke_angle", 0.001), + case("symbol", "zindex_circles", 0.001), + case("symbol", "mixed_symbols", 0.001), + case("rule", "wide_rule_axes", 0.0001), + case("rule", "wide_transparent_caps", 0.0001), + case("text", "bar_axis_labels", 0.025) )] fn test_image_baseline(category: &str, spec_name: &str, tolerance: f64) { let specs_dir = format!(