Skip to content

Commit fa6b1db

Browse files
Add GPU stroke expand shader & refactor strokes
1 parent 2f0a7f6 commit fa6b1db

37 files changed

Lines changed: 1207 additions & 550 deletions

File tree

src/ImageSharp.Drawing.WebGPU/Shaders/CompositeComputeShader.cs

Lines changed: 10 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ struct Edge {
2525
x1: i32,
2626
y1: i32,
2727
flags: i32,
28+
adj_x: i32,
29+
adj_y: i32,
2830
}
2931
3032
struct Params {
@@ -54,12 +56,8 @@ struct Params {
5456
solid_a: u32,
5557
rasterization_mode: u32,
5658
antialias_threshold: u32,
57-
stroke_mode: u32,
58-
stroke_half_width: u32,
59-
stroke_line_cap: u32,
60-
stroke_line_join: u32,
61-
stroke_miter_limit: u32,
62-
stroke_pad0: u32,
59+
pad0: u32,
60+
pad1: u32,
6361
};
6462
6563
struct DispatchConfig {
@@ -814,148 +812,6 @@ fn area_to_coverage(area_val: i32, fill_rule: u32, rasterization_mode: u32, anti
814812
return coverage;
815813
}
816814
817-
// -----------------------------------------------------------------------
818-
// Stroke distance-field helpers
819-
// -----------------------------------------------------------------------
820-
821-
// Returns vec2(squared_distance, unclamped_t) from point p to line segment a→b.
822-
// The returned distance uses clamped t (0..1), but the unclamped t is also returned
823-
// so callers can detect endpoint proximity for cap handling.
824-
fn dist_to_segment(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
825-
let ab = b - a;
826-
let ap = p - a;
827-
let len_sq = dot(ab, ab);
828-
var unclamped_t: f32;
829-
if len_sq < 1e-10 {
830-
unclamped_t = 0.0;
831-
} else {
832-
unclamped_t = dot(ap, ab) / len_sq;
833-
}
834-
let t = clamp(unclamped_t, 0.0, 1.0);
835-
let closest = a + ab * t;
836-
let d = p - closest;
837-
return vec2<f32>(dot(d, d), unclamped_t);
838-
}
839-
840-
// Edge flags (matches C# GpuEdge.Flags bit layout).
841-
const EDGE_OPEN_START: i32 = 1; // (x0,y0) is an open path start
842-
const EDGE_OPEN_END: i32 = 2; // (x1,y1) is an open path end
843-
844-
// LineCap enum values.
845-
const CAP_BUTT: u32 = 0u;
846-
const CAP_SQUARE: u32 = 1u;
847-
const CAP_ROUND: u32 = 2u;
848-
849-
// Computes stroke coverage for a pixel using distance-field evaluation.
850-
// Iterates all edges in relevant bands, finds min distance to centerline,
851-
// applies line cap rules using edge flags, and returns antialiased coverage.
852-
//
853-
// Line join handling:
854-
// Miter/MiterRevert/MiterRound joins are handled by extending centerline
855-
// segments past interior vertices on the CPU side. The distance field then
856-
// naturally produces the correct miter coverage.
857-
// Round joins are the natural distance-field behavior.
858-
// Bevel join coverage is very close to round and accepted as-is.
859-
fn stroke_coverage(
860-
dest_x_i32: i32,
861-
dest_y_i32: i32,
862-
command: Params,
863-
half_width: f32,
864-
line_cap: u32,
865-
line_join: u32,
866-
miter_limit: f32,
867-
tile_min_x: i32,
868-
tile_min_y: i32,
869-
) -> f32 {
870-
let px = f32(dest_x_i32 - command.edge_origin_x) + 0.5;
871-
let py = f32(dest_y_i32 - command.edge_origin_y) + 0.5;
872-
let point = vec2<f32>(px, py);
873-
874-
// Determine band range for this pixel. Edges are stored per 16-row band.
875-
// We must check all bands whose edges could be within half_width + 1 distance.
876-
let expand = i32(ceil(half_width)) + 1;
877-
let pixel_y = i32(py);
878-
var first_band = max((pixel_y - expand) / 16, 0);
879-
if (pixel_y - expand) < 0 && ((pixel_y - expand) % 16) != 0 {
880-
first_band = max(first_band - 1, 0);
881-
}
882-
let last_band = min((pixel_y + expand) / 16, i32(command.csr_band_count) - 1);
883-
if first_band > last_band || i32(command.csr_band_count) == 0 {
884-
return 0.0;
885-
}
886-
887-
let sentinel = half_width * half_width + half_width * 2.0 + 1.0;
888-
var min_dist_sq = sentinel;
889-
let inv_fixed = 1.0 / f32(FIXED_ONE);
890-
891-
for (var band = first_band; band <= last_band; band++) {
892-
let b_start = band_offsets[command.csr_offsets_start + u32(band)];
893-
let b_end = band_offsets[command.csr_offsets_start + u32(band) + 1u];
894-
for (var ei = b_start; ei < b_end; ei++) {
895-
let edge = edges[command.edge_start + ei];
896-
let a = vec2<f32>(f32(edge.x0) * inv_fixed, f32(edge.y0) * inv_fixed);
897-
let b = vec2<f32>(f32(edge.x1) * inv_fixed, f32(edge.y1) * inv_fixed);
898-
let flags = edge.flags;
899-
let is_open_start = (flags & EDGE_OPEN_START) != 0;
900-
let is_open_end = (flags & EDGE_OPEN_END) != 0;
901-
902-
let result = dist_to_segment(point, a, b);
903-
var d_sq = result.x;
904-
let unclamped_t = result.y;
905-
906-
// Cap handling at open path endpoints.
907-
// Interior vertices (no open flags) use the natural clamped distance,
908-
// which produces smooth coverage where adjacent segments meet.
909-
if line_cap == CAP_BUTT {
910-
// Butt cap: no coverage past the open endpoint.
911-
// Exclude this edge if the pixel projects past an open end.
912-
if (unclamped_t < 0.0 && is_open_start) || (unclamped_t > 1.0 && is_open_end) {
913-
d_sq = sentinel;
914-
}
915-
} else if line_cap == CAP_SQUARE {
916-
// Square cap: extend the segment by half_width at open endpoints only.
917-
let needs_ext = (unclamped_t < 0.0 && is_open_start) || (unclamped_t > 1.0 && is_open_end);
918-
if needs_ext {
919-
let seg = b - a;
920-
let seg_len = length(seg);
921-
if seg_len > 1e-6 {
922-
let dir = seg / seg_len;
923-
var ext_a = a;
924-
var ext_b = b;
925-
if is_open_start {
926-
ext_a = a - dir * half_width;
927-
}
928-
if is_open_end {
929-
ext_b = b + dir * half_width;
930-
}
931-
let ext_result = dist_to_segment(point, ext_a, ext_b);
932-
d_sq = ext_result.x;
933-
}
934-
}
935-
// For non-open endpoints or when projection is on-segment,
936-
// use the natural clamped distance (d_sq unchanged).
937-
}
938-
// CAP_ROUND: natural clamped distance produces round caps. No change needed.
939-
940-
min_dist_sq = min(min_dist_sq, d_sq);
941-
}
942-
}
943-
944-
let min_dist = sqrt(min_dist_sq);
945-
946-
// Antialiased coverage.
947-
let rasterization_mode = command.rasterization_mode;
948-
if rasterization_mode == 1u {
949-
// Aliased mode.
950-
if min_dist <= half_width {
951-
return 1.0;
952-
}
953-
return 0.0;
954-
}
955-
// Antialiased: smooth transition over 1 pixel at boundary.
956-
return clamp(half_width + 0.5 - min_dist, 0.0, 1.0);
957-
}
958-
959815
// -----------------------------------------------------------------------
960816
// Main entry point
961817
// -----------------------------------------------------------------------
@@ -1011,31 +867,13 @@ fn cs_main(
1011867
continue;
1012868
}
1013869
1014-
// Branch: stroke mode vs fill mode.
1015-
let is_stroke = command.stroke_mode == 1u;
1016-
1017870
var coverage_value = 0.0;
1018-
if is_stroke {
1019-
// Stroke path: per-pixel distance-field evaluation.
1020-
if in_bounds && dest_x_i32 >= cmd_min_x && dest_x_i32 < cmd_max_x && dest_y_i32 >= cmd_min_y && dest_y_i32 < cmd_max_y {
1021-
let half_width = u32_to_f32(command.stroke_half_width);
1022-
coverage_value = stroke_coverage(
1023-
dest_x_i32, dest_y_i32, command,
1024-
half_width,
1025-
command.stroke_line_cap,
1026-
command.stroke_line_join,
1027-
u32_to_f32(command.stroke_miter_limit),
1028-
tile_min_x, tile_min_y);
1029-
}
1030-
} else {
1031-
// Fill path: scanline rasterizer.
1032871
1033-
// Determine this tile's position in coverage-local space.
872+
// Tile position in edge-local (coverage-local) space.
1034873
let band_top = tile_min_y - command.edge_origin_y;
1035874
let band_left_fixed = (tile_min_x - command.edge_origin_x) << FIXED_SHIFT;
1036875
1037-
// Band lookup: when edge_origin_y is 16-aligned the tile maps to one band;
1038-
// otherwise it can overlap two bands.
876+
// Multi-band lookup: tile may overlap one or two bands.
1039877
var first_band = band_top / 16;
1040878
if band_top < 0 && (band_top % 16) != 0 {
1041879
first_band -= 1;
@@ -1082,7 +920,9 @@ fn cs_main(
1082920
break;
1083921
}
1084922
let edge = edges[command.edge_start + b_start + ei];
1085-
if min(edge.x0, edge.x1) >= tile_right_fixed {
923+
if edge.y0 == edge.y1 {
924+
// Skip degenerate edges (sentinel slots from stroke expand).
925+
} else if min(edge.x0, edge.x1) >= tile_right_fixed {
1086926
} else if max(edge.x0, edge.x1) < band_left_fixed {
1087927
accumulate_start_cover(edge.y0, edge.y1, clip_top, clip_bottom, tile_top_fixed);
1088928
} else {
@@ -1093,7 +933,7 @@ fn cs_main(
1093933
}
1094934
workgroupBarrier();
1095935
1096-
// Compute coverage for fill.
936+
// Compute coverage.
1097937
if in_bounds {
1098938
if dest_x_i32 >= cmd_min_x && dest_x_i32 < cmd_max_x && dest_y_i32 >= cmd_min_y && dest_y_i32 < cmd_max_y {
1099939
var cover = atomicLoad(&tile_start_cover[py]);
@@ -1104,7 +944,6 @@ fn cs_main(
1104944
coverage_value = area_to_coverage(area_val, command.fill_rule_value, command.rasterization_mode, u32_to_f32(command.antialias_threshold));
1105945
}
1106946
}
1107-
} // end fill path
1108947
1109948
// Compose coverage result (shared by fill and stroke paths).
1110949
if in_bounds && coverage_value > 0.0 {

0 commit comments

Comments
 (0)