From f74ba328af47a546942875a1ce198e7118ad617d Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa <11414628+koubaa@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:07:22 -0500 Subject: [PATCH 1/7] fix curve dashes --- Cargo.lock | 4 +- ekrano/src/goldy_renderer.rs | 29 +++- ekrano/src/scene.rs | 18 +-- ekrano/src/scheme_render.rs | 133 ++++++++-------- ekrano/src/scheme_renderer.rs | 6 +- ekrano/src/shaders.rs | 2 +- ekrano_encoding/src/config.rs | 5 +- ekrano_encoding/src/path.rs | 43 ++---- ekrano_shaders/slang/ekrano_shared.slang | 1 - ekrano_shaders/slang/flatten.slang | 144 +----------------- ekrano_shaders/slang/pathtag_scan1.slang | 56 +++++++ ekrano_shaders/slang/pathtag_scan_large.slang | 39 ++--- ekrano_shaders/src/slang.rs | 2 +- ekrano_tests/snapshots/dashed_curves.png | 3 + ekrano_tests/tests/snapshot_test_scenes.rs | 13 ++ examples/scenes/src/test_scenes.rs | 33 ++++ 16 files changed, 253 insertions(+), 278 deletions(-) create mode 100644 ekrano_shaders/slang/pathtag_scan1.slang create mode 100644 ekrano_tests/snapshots/dashed_curves.png diff --git a/Cargo.lock b/Cargo.lock index 44f41882..95024b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "goldy" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "ash", @@ -787,7 +787,7 @@ dependencies = [ [[package]] name = "goldy_derive" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proc-macro2", "quote", diff --git a/ekrano/src/goldy_renderer.rs b/ekrano/src/goldy_renderer.rs index 425e5aff..408fdbbb 100644 --- a/ekrano/src/goldy_renderer.rs +++ b/ekrano/src/goldy_renderer.rs @@ -594,11 +594,30 @@ impl PersistentState { } pub(crate) fn drain_ready_bump_readbacks(&mut self, _ctx: &Context) -> Result<()> { + self.drain_bump_readbacks(false) + } + + /// Wait for the pending bump submission (if any), then claim the readback. + /// + /// Required on headless/`render_to_buffer` paths when + /// `host_sidecar_on_submit_worker` makes the frame orchestrator close with + /// `end_frame_externally_ordered` — `drain_all` then does not wait for the + /// scheme submission, so a non-blocking drain would skip bump feedback and + /// never retry after overflow. + pub(crate) fn wait_and_drain_bump_readbacks(&mut self, _ctx: &Context) -> Result<()> { + self.drain_bump_readbacks(true) + } + + fn drain_bump_readbacks(&mut self, wait: bool) -> Result<()> { let Some(mut submission) = self.pending_bump_submission.take() else { return Ok(()); }; if let Some(withdraw) = self.cached_bump_withdraw.as_ref() { - if !submission.is_settled() { + if wait { + submission + .wait_until_settled() + .map_err(|e| Error::Shader(e.to_string()))?; + } else if !submission.is_settled() { self.pending_bump_submission = Some(submission); return Ok(()); } @@ -611,7 +630,13 @@ impl PersistentState { read_bump_bytes(self, &bytes); return Ok(()); } - self.pending_bump_submission = Some(submission); + if wait { + submission + .wait_until_settled() + .map_err(|e| Error::Shader(e.to_string()))?; + } else { + self.pending_bump_submission = Some(submission); + } Ok(()) } diff --git a/ekrano/src/scene.rs b/ekrano/src/scene.rs index 83a7b21e..0b0f3640 100644 --- a/ekrano/src/scene.rs +++ b/ekrano/src/scene.rs @@ -477,11 +477,14 @@ impl Scene { let encoded_stroke = self.encoding.encode_stroke_style(style); debug_assert!(encoded_stroke, "Stroke width is non-zero"); - // 2-element dash patterns are handled on the GPU (encoded in the Style). - // Longer patterns still fall back to CPU dashing. - let use_cpu_dash = !style.dash_pattern.is_empty() && style.dash_pattern.len() != 2; - - if use_cpu_dash { + // Match Vello's established behavior: dashes are expanded into path segments before the + // GPU stroke stage. + if style.dash_pattern.is_empty() { + #[cfg(feature = "bump_estimate")] + self.estimator + .count_path(shape.path_elements(SHAPE_TOLERANCE), &t, Some(style)); + self.encoding.encode_shape(shape, false) + } else { let dashed = peniko::kurbo::dash( shape.path_elements(SHAPE_TOLERANCE), style.dash_offset, @@ -491,11 +494,6 @@ impl Scene { #[cfg(feature = "bump_estimate")] self.estimator.count_path(dashed.iter().copied(), &t, Some(style)); self.encoding.encode_path_elements(dashed.into_iter(), false) - } else { - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(shape.path_elements(SHAPE_TOLERANCE), &t, Some(style)); - self.encoding.encode_shape(shape, false) } } diff --git a/ekrano/src/scheme_render.rs b/ekrano/src/scheme_render.rs index 7f0f98fa..098c0801 100644 --- a/ekrano/src/scheme_render.rs +++ b/ekrano/src/scheme_render.rs @@ -106,11 +106,8 @@ pub struct CapturedBuffers { pub sizes: ekrano_encoding::BufferSizes, } -/// Max flatten workgroups per queue submit. Large single dispatches can exceed the -/// Windows ~2s GPU timeout (TDR) on stressed dashed paths. -const MAX_FLATTEN_WG_PER_SUBMIT: u32 = 8; -/// Must match `FLATTEN_WG` in `ekrano_encoding` (threads per flatten workgroup). -const FLATTEN_THREADS_PER_GROUP: u32 = 256; +/// Flatten uses a single dispatch (`ConfigUniform::flatten_thread_base` stays 0). +/// Chunking via push-constant `thread_base` under-counted work on DX12. fn dispatch_stage( recorder: &mut SchemeRecorder<'_>, @@ -174,6 +171,14 @@ impl Render { .as_ref() .expect("alloc_or_reuse_scheme_indirect must produce indirect buffer"); + if wg_counts.use_large_path_scan { + // First-level reduce only fills `path_tag_wgs` slots; `reduced` is aligned up + // to a multiple of 256 for reduce2. Zero padding so unused slots are identity. + recorder + .scheme() + .clear_parcel(&pipeline.scratch.reduced, 0, 0) + .expect("clear path_reduced before large pathtag scan"); + } dispatch_stage( recorder, indirect_buf, @@ -185,51 +190,54 @@ impl Render { pipeline.scratch.reduced.as_binding(), ], ); - dispatch_stage( - recorder, - indirect_buf, - shaders.pathtag_reduce2, - STAGE_PATHTAG_REDUCE2, - &[ - pipeline.scratch.reduced.as_binding(), - pipeline.scratch.reduced2.as_binding(), - ], - ); - dispatch_stage( - recorder, - indirect_buf, - shaders.pathtag_scan1, - STAGE_PATHTAG_SCAN1, - &[ - pipeline.scratch.reduced.as_binding(), - pipeline.scratch.reduced2.as_binding(), - pipeline.scratch.reduced_scan.as_binding(), - ], - ); - dispatch_stage( - recorder, - indirect_buf, - shaders.pathtag_scan, - STAGE_PATHTAG_SCAN, - &[ - pipeline.config.as_binding(), - pipeline.scene.as_binding(), - pipeline.scratch.reduced.as_binding(), - pipeline.scratch.tagmonoid.as_binding(), - ], - ); - dispatch_stage( - recorder, - indirect_buf, - shaders.pathtag_scan_large, - STAGE_PATHTAG_SCAN_LARGE, - &[ - pipeline.config.as_binding(), - pipeline.scene.as_binding(), - pipeline.scratch.reduced_scan.as_binding(), - pipeline.scratch.tagmonoid.as_binding(), - ], - ); + if wg_counts.use_large_path_scan { + dispatch_stage( + recorder, + indirect_buf, + shaders.pathtag_reduce2, + STAGE_PATHTAG_REDUCE2, + &[ + pipeline.scratch.reduced.as_binding(), + pipeline.scratch.reduced2.as_binding(), + ], + ); + dispatch_stage( + recorder, + indirect_buf, + shaders.pathtag_scan1, + STAGE_PATHTAG_SCAN1, + &[ + pipeline.scratch.reduced.as_binding(), + pipeline.scratch.reduced2.as_binding(), + pipeline.scratch.reduced_scan.as_binding(), + ], + ); + dispatch_stage( + recorder, + indirect_buf, + shaders.pathtag_scan_large, + STAGE_PATHTAG_SCAN_LARGE, + &[ + pipeline.config.as_binding(), + pipeline.scene.as_binding(), + pipeline.scratch.reduced_scan.as_binding(), + pipeline.scratch.tagmonoid.as_binding(), + ], + ); + } else { + dispatch_stage( + recorder, + indirect_buf, + shaders.pathtag_scan, + STAGE_PATHTAG_SCAN, + &[ + pipeline.config.as_binding(), + pipeline.scene.as_binding(), + pipeline.scratch.reduced.as_binding(), + pipeline.scratch.tagmonoid.as_binding(), + ], + ); + } dispatch_stage( recorder, @@ -247,24 +255,13 @@ impl Render { pipeline.bump.as_binding(), pipeline.stable.lines.as_binding(), ]; - let flat_wg_x = wg_counts.flatten.0; - if flat_wg_x > MAX_FLATTEN_WG_PER_SUBMIT { - let mut base_wg = 0_u32; - while base_wg < flat_wg_x { - let chunk = (flat_wg_x - base_wg).min(MAX_FLATTEN_WG_PER_SUBMIT); - let thread_base = base_wg * FLATTEN_THREADS_PER_GROUP; - recorder.dispatch_with_push_tail(shaders.flatten, (chunk, 1, 1), &flatten_bindings, &[thread_base]); - base_wg += chunk; - } - } else { - dispatch_stage( - recorder, - indirect_buf, - shaders.flatten, - STAGE_FLATTEN, - &flatten_bindings, - ); - } + dispatch_stage( + recorder, + indirect_buf, + shaders.flatten, + STAGE_FLATTEN, + &flatten_bindings, + ); dispatch_stage( recorder, diff --git a/ekrano/src/scheme_renderer.rs b/ekrano/src/scheme_renderer.rs index 61e77790..328c8d65 100644 --- a/ekrano/src/scheme_renderer.rs +++ b/ekrano/src/scheme_renderer.rs @@ -468,7 +468,10 @@ impl SchemeRenderer { self.frame_pipeline .drain_all() .map_err(|e| Error::Shader(e.to_string()))?; - self.drain_ready_bump_readbacks()?; + // Must wait: with host-sidecar / nonblocking reuse the orchestrator ring + // does not fence the scheme submission, so a poll-only drain skips bump + // feedback and leaves overflowed frames unrecovered. + self.persistent.wait_and_drain_bump_readbacks(&self.context)?; self.context.flush_deferred_deletions(); match self.persistent.last_drained_bump() { @@ -1385,6 +1388,7 @@ impl<'a> SchemeRecorder<'a> { Self::record_dispatch(self.scheme, self.shaders, shader, wg_size, bindings, &[]); } + #[allow(dead_code, reason = "kept for reintroducing config-based flatten chunking")] pub fn dispatch_with_push_tail( &mut self, shader: ShaderId, diff --git a/ekrano/src/shaders.rs b/ekrano/src/shaders.rs index b9f661c1..4cd0b2c1 100644 --- a/ekrano/src/shaders.rs +++ b/ekrano/src/shaders.rs @@ -117,7 +117,7 @@ pub(crate) fn goldy_full_shaders_scheme( )?; let pathtag_scan_large = renderer.add_compute_shader( "pathtag_scan_large", - ekrano_shaders::slang::PATHTAG_SCAN_SMALL, + ekrano_shaders::slang::PATHTAG_SCAN_LARGE, &[BufReadOnly, BufReadOnly, BufReadOnly, Buffer], &search_paths, &[], diff --git a/ekrano_encoding/src/config.rs b/ekrano_encoding/src/config.rs index 2d516968..5df7ee83 100644 --- a/ekrano_encoding/src/config.rs +++ b/ekrano_encoding/src/config.rs @@ -313,7 +313,10 @@ impl WorkgroupCounts { Self { use_large_path_scan, path_reduce: (path_tag_wgs, 1, 1), - path_reduce2: (PATH_REDUCE_WG, 1, 1), + // One reduce2 workgroup per 256 first-level reductions. Vello dispatches a + // fixed PATH_REDUCE_WG here; that OOB-reads the reduced buffer on backends + // without robust buffer access (DX12), corrupting the large-scan ladder. + path_reduce2: (reduced_size / PATH_REDUCE_WG, 1, 1), path_scan1: (reduced_size / PATH_REDUCE_WG, 1, 1), path_scan: (path_tag_wgs, 1, 1), bbox_clear: (draw_object_wgs, 1, 1), diff --git a/ekrano_encoding/src/path.rs b/ekrano_encoding/src/path.rs index 2c35fed7..fd19c8c3 100644 --- a/ekrano_encoding/src/path.rs +++ b/ekrano_encoding/src/path.rs @@ -12,13 +12,12 @@ use super::Monoid; /// Layout (4 × u32 = 16 bytes): /// - Word 0: flags + miter limit (see below) /// - Word 1: stroke line width (f32) -/// - Word 2: dash offset (f32, 0.0 if not dashed) -/// - Word 3: packed dash pattern — `dash_on` (f16, bits 0-15) | `dash_off` (f16, bits 16-31) +/// - Words 2-3: reserved /// /// Flags layout in word 0 (upper 16 bits): /// ```text -/// |style|fill|join|start cap|end cap|dashed|reserved| -/// 31 30 29-28 27-26 25-24 23 22-16 +/// |style|fill|join|start cap|end cap|reserved| +/// 31 30 29-28 27-26 25-24 23-16 /// ``` /// Lower 16 bits: miter limit as binary16. #[derive(Clone, Copy, Debug, Zeroable, Pod, Default, PartialEq)] @@ -27,11 +26,10 @@ pub struct Style { pub flags_and_miter_limit: u32, /// Encodes the stroke width. This field is ignored for fills. pub line_width: f32, - /// Dash offset in user-space units. 0.0 when not dashed. - pub dash_offset: f32, - /// Packed dash pattern: lower 16 bits = `dash_on` (f16), upper 16 bits = `dash_off` (f16). - /// Zero when not dashed. - pub dash_pattern: u32, + /// Reserved for future style data. + pub reserved_0: u32, + /// Reserved for future style data. + pub reserved_1: u32, } impl Style { @@ -68,9 +66,6 @@ impl Style { pub const FLAGS_START_CAP_MASK: u32 = 0x0C00_0000; pub const FLAGS_END_CAP_MASK: u32 = 0x0300_0000; - /// Set when the stroke has a dash pattern (GPU-side dashing). - pub const FLAGS_DASHED_BIT: u32 = 0x0080_0000; - pub const MITER_LIMIT_MASK: u32 = 0xFFFF; pub fn from_fill(fill: Fill) -> Self { @@ -81,15 +76,14 @@ impl Style { Self { flags_and_miter_limit: fill_bit, line_width: 0., - dash_offset: 0., - dash_pattern: 0, + reserved_0: 0, + reserved_1: 0, } } /// Creates a style from a stroke. /// /// As it isn't meaningful to encode a zero width stroke, returns None if the width is zero. - /// When the stroke has a 2-element dash pattern, it is encoded for GPU-side dashing. pub fn from_stroke(stroke: &Stroke) -> Option { if stroke.width == 0.0 { return None; @@ -112,27 +106,14 @@ impl Style { }; let miter_limit = crate::math::f32_to_f16(stroke.miter_limit as f32) as u32; - let (dashed, dash_offset, dash_packed) = if stroke.dash_pattern.len() == 2 { - let on = crate::math::f32_to_f16(stroke.dash_pattern[0] as f32) as u32; - let off = crate::math::f32_to_f16(stroke.dash_pattern[1] as f32) as u32; - (Self::FLAGS_DASHED_BIT, stroke.dash_offset as f32, on | (off << 16)) - } else { - (0, 0.0, 0) - }; - Some(Self { - flags_and_miter_limit: style | join | start_cap | end_cap | miter_limit | dashed, + flags_and_miter_limit: style | join | start_cap | end_cap | miter_limit, line_width: stroke.width as f32, - dash_offset, - dash_pattern: dash_packed, + reserved_0: 0, + reserved_1: 0, }) } - /// Returns true if this style has a GPU-side dash pattern. - pub fn is_dashed(&self) -> bool { - (self.flags_and_miter_limit & Self::FLAGS_DASHED_BIT) != 0 - } - #[cfg(test)] fn fill(self) -> Option { if self.is_fill() { diff --git a/ekrano_shaders/slang/ekrano_shared.slang b/ekrano_shaders/slang/ekrano_shared.slang index 26e4ff81..979ac852 100644 --- a/ekrano_shaders/slang/ekrano_shared.slang +++ b/ekrano_shaders/slang/ekrano_shared.slang @@ -176,7 +176,6 @@ public static const uint STYLE_FLAGS_JOIN_MASK = 0x30000000; public static const uint STYLE_FLAGS_JOIN_BEVEL = 0; public static const uint STYLE_FLAGS_JOIN_MITER = 0x10000000; public static const uint STYLE_FLAGS_JOIN_ROUND = 0x20000000; -public static const uint STYLE_FLAGS_DASHED = 0x00800000; // === Drawtag === public struct DrawMonoid { diff --git a/ekrano_shaders/slang/flatten.slang b/ekrano_shaders/slang/flatten.slang index b32c7a88..0d31370f 100644 --- a/ekrano_shaders/slang/flatten.slang +++ b/ekrano_shaders/slang/flatten.slang @@ -725,124 +725,11 @@ void draw_join(FlattenCtx ctx, uint path_ix, uint style_flags, float2 p0, float2 } } -// ---- GPU-side dashing helpers ---- - -// Compute cumulative arc length from the start of the current subpath to -// the beginning of segment at index `ix`. Uses a backward walk to find the -// subpath start, then a forward walk to sum chord lengths. -float compute_subpath_arc(FlattenCtx ctx, uint ix, uint my_path_ix) { - // Walk backward to find subpath start. Stop at: - // - PATH_TAG_PATH markers (draw object boundary) - // - Tags with SUBPATH_END set (end of previous subpath) - uint start_ix = ix; - const uint MAX_WALK = 512; - for (uint i = 1; i <= MAX_WALK && ix >= i; i++) { - uint prev_ix = ix - i; - uint tw = ctx.scene[ctx.config.pathtag_base + (prev_ix >> 2)]; - uint sh = (prev_ix & 3) * 8; - uint tb = (tw >> sh) & 0xFF; - if ((tb & PATH_TAG_PATH) != 0) break; - if ((tb & PATH_TAG_SUBPATH_END) != 0) break; - start_ix = prev_ix; - } - - // Forward walk: accumulate chord lengths of real (non-cap) segments before ix. - float arc = 0.0; - for (uint j = start_ix; j < ix; j++) { - PathTagData ptag = compute_tag_monoid(ctx, j); - uint seg = ptag.tag_byte & PATH_TAG_SEG_TYPE; - if (seg == 0) continue; - if ((ptag.tag_byte & PATH_TAG_SUBPATH_END) != 0) continue; - CubicPoints p = read_path_segment(ctx, ptag, true); - arc += distance(p.p0, p.p3); - } - return arc; -} - -// Emit a single stroke dash sub-segment using the same flatten_euler path -// as normal strokes, ensuring identical rendering across backends. -void emit_dash_stroke( - FlattenCtx ctx, - uint path_ix, - uint style_flags, - float2 sp0, - float2 sp3, - float offset, - Transform transform, - inout float4 bbox -) { - float2 tangent = sp3 - sp0; - float tang_len = length(tangent); - if (tang_len < 1e-12) return; - float2 tn = tangent / tang_len; - float2 offset_tangent = offset * tn; - float2 n = offset_tangent.yx * float2(-1.0, 1.0); - - uint start_cap = (style_flags & STYLE_FLAGS_START_CAP_MASK) >> 2; - uint end_cap = (style_flags & STYLE_FLAGS_END_CAP_MASK); - draw_cap(ctx, path_ix, start_cap, sp0, sp0 - n, sp0 + n, -offset_tangent, transform, bbox); - draw_cap(ctx, path_ix, end_cap, sp3, sp3 + n, sp3 - n, offset_tangent, transform, bbox); - - output_line_with_transform(ctx, path_ix, sp0 + n, sp3 + n, transform, bbox); - output_line_with_transform(ctx, path_ix, sp3 - n, sp0 - n, transform, bbox); -} - -// Process one segment of a dashed stroke: find visible intervals and emit them. -void process_dashed_segment( - FlattenCtx ctx, - uint path_ix, - uint style_flags, - CubicPoints pts, - float offset, - Transform transform, - float cum_arc, - float dash_offset_val, - float dash_on, - float dash_off, - inout float4 bbox -) { - float period = dash_on + dash_off; - if (period < 1e-9) return; - float seg_len = distance(pts.p0, pts.p3); - if (seg_len < 1e-12) return; - - float seg_start = cum_arc; - float seg_end = seg_start + seg_len; - float pos = seg_start; - - const uint MAX_INTERVALS = 32; - float snap_eps = period * 1e-5; - for (uint di = 0; di < MAX_INTERVALS && pos < seg_end - 1e-6; di++) { - float adjusted = pos + dash_offset_val; - float in_period = fmod(adjusted, period); - if (in_period < 0.0) in_period += period; - if (in_period < snap_eps || period - in_period < snap_eps) in_period = 0.0; - if (abs(in_period - dash_on) < snap_eps) in_period = dash_on; - bool visible = in_period < dash_on; - float to_next = visible ? (dash_on - in_period) : (period - in_period); - to_next = max(to_next, 1e-6); - float interval_end = min(pos + to_next, seg_end); - - if (visible) { - float t0 = (pos - seg_start) / seg_len; - float t1 = (interval_end - seg_start) / seg_len; - t0 = clamp(t0, 0.0, 1.0); - t1 = clamp(t1, 0.0, 1.0); - float2 sp0 = lerp(pts.p0, pts.p3, t0); - float2 sp3 = lerp(pts.p0, pts.p3, t1); - emit_dash_stroke(ctx, path_ix, style_flags, sp0, sp3, offset, transform, bbox); - } - - pos = interval_end; - } -} - [goldy_compute] [numthreads(256, 1, 1)] void cs_main(BufRO config_buf, BufRO scene, BufRO tag_monoids, ByteAddress path_bboxes, Scattered bump, Scattered lines, - uint thread_base, ThreadId global_id) { Config config = config_buf[0]; @@ -855,7 +742,7 @@ void cs_main(BufRO config_buf, BufRO scene, ctx.lines = lines; ctx.pathdata_base = config.pathdata_base; - uint ix = global_id.x + thread_base; + uint ix = global_id.x + config.flatten_thread_base; float4 bbox = Bbox2D.empty().v; PathTagData tag = compute_tag_monoid(ctx, ix); @@ -882,30 +769,13 @@ void cs_main(BufRO config_buf, BufRO scene, float offset = 0.5 * linewidth; bool is_open = (tag.tag_byte & PATH_TAG_SEG_TYPE) != PATH_TAG_LINETO; bool is_stroke_cap_marker = (tag.tag_byte & PATH_TAG_SUBPATH_END) != 0; - bool is_dashed = (style_flags & STYLE_FLAGS_DASHED) != 0; - - if (is_dashed && !is_stroke_cap_marker) { - float dash_offset_val = asfloat(scene[config.style_base + style_ix + 2]); - uint packed_dash = scene[config.style_base + style_ix + 3]; - float dash_on = f16tof32(packed_dash & 0xFFFF); - float dash_off = f16tof32(packed_dash >> 16); - - float cum_arc = compute_subpath_arc(ctx, ix, path_ix); - process_dashed_segment( - ctx, path_ix, style_flags, pts, offset, transform, - cum_arc, dash_offset_val, dash_on, dash_off, bbox - ); - } else if (is_stroke_cap_marker) { + + if (is_stroke_cap_marker) { if (is_open) { - if (is_dashed) { - // Dashed strokes: each dash already has its own caps - // from emit_dash_stroke, so no subpath-level cap needed. - } else { - float2 tangent = pts.p3 - pts.p0; - float2 offset_tangent = offset * normalize(tangent); - float2 n = offset_tangent.yx * float2(-1.0, 1.0); - draw_cap(ctx, path_ix, (style_flags & STYLE_FLAGS_START_CAP_MASK) >> 2, pts.p0, pts.p0 - n, pts.p0 + n, -offset_tangent, transform, bbox); - } + float2 tangent = pts.p3 - pts.p0; + float2 offset_tangent = offset * normalize(tangent); + float2 n = offset_tangent.yx * float2(-1.0, 1.0); + draw_cap(ctx, path_ix, (style_flags & STYLE_FLAGS_START_CAP_MASK) >> 2, pts.p0, pts.p0 - n, pts.p0 + n, -offset_tangent, transform, bbox); } } else { NeighboringSegment neighbor = read_neighboring_segment(ctx, ix + 1); diff --git a/ekrano_shaders/slang/pathtag_scan1.slang b/ekrano_shaders/slang/pathtag_scan1.slang new file mode 100644 index 00000000..fe34af89 --- /dev/null +++ b/ekrano_shaders/slang/pathtag_scan1.slang @@ -0,0 +1,56 @@ +// Copyright 2023 the Vello Authors +// Copyright 2026 the Ekrano Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT OR Unlicense +// +// Pathtag scan1 - middle level for the large two-level monoid scan. +// Combines first-level `reduced` blocks using `reduced2` parent prefixes into +// `tag_monoids` (consumed as `reduced` by pathtag_scan_large). + +import goldy_exp; +import ekrano_shared; + +static const uint LG_WG_SIZE = 8; +static const uint WG_SIZE = 256; + +// Slots: 0 = reduced, 1 = reduced2, 2 = tag_monoids +groupshared TagMonoid sh_parent[256]; +groupshared TagMonoid sh_monoid[256]; + +[goldy_compute] +[numthreads(256, 1, 1)] +void cs_main(BufRO reduced, BufRO reduced2, Scattered tag_monoids, + ThreadId global_id, GroupThreadId local_id, GroupId wg_id) { + + TagMonoid agg = TagMonoid.identity(); + if (local_id.x < wg_id.x) { + agg = reduced2[local_id.x]; + } + sh_parent[local_id.x] = agg; + for (uint i = 0; i < LG_WG_SIZE; i++) { + GroupMemoryBarrierWithGroupSync(); + if (local_id.x + (1u << i) < WG_SIZE) + agg = agg.combine(sh_parent[local_id.x + (1u << i)]); + GroupMemoryBarrierWithGroupSync(); + sh_parent[local_id.x] = agg; + } + + uint ix = global_id.x; + agg = reduced[ix]; + sh_monoid[local_id.x] = agg; + for (uint i = 0; i < LG_WG_SIZE; i++) { + GroupMemoryBarrierWithGroupSync(); + if (local_id.x >= (1u << i)) { + TagMonoid other = sh_monoid[local_id.x - (1u << i)]; + agg = other.combine(agg); + } + GroupMemoryBarrierWithGroupSync(); + sh_monoid[local_id.x] = agg; + } + GroupMemoryBarrierWithGroupSync(); + + TagMonoid tm = sh_parent[0]; + if (local_id.x > 0) { + tm = tm + sh_monoid[local_id.x - 1]; + } + tag_monoids[ix] = tm; +} diff --git a/ekrano_shaders/slang/pathtag_scan_large.slang b/ekrano_shaders/slang/pathtag_scan_large.slang index 97aebc8a..e2dd3af5 100644 --- a/ekrano_shaders/slang/pathtag_scan_large.slang +++ b/ekrano_shaders/slang/pathtag_scan_large.slang @@ -1,8 +1,12 @@ -// Copyright 2023 the Vello Authors +// Copyright 2022 the Vello Authors // Copyright 2026 the Ekrano Authors // SPDX-License-Identifier: Apache-2.0 OR MIT OR Unlicense // -// Pathtag scan - large (two-level) variant. +// Pathtag scan - large (two-level) final stage. +// +// `reduced` is the exclusive prefix per first-level workgroup produced by +// pathtag_scan1. Unlike the small scan, this must not fold `reduced[0..wg_id]` +// in shared memory (that only works for wg_id < 256). import goldy_exp; import ekrano_shared; @@ -10,43 +14,32 @@ import ekrano_shared; static const uint LG_WG_SIZE = 8; static const uint WG_SIZE = 256; -// Slots: 0 = reduced, 1 = reduced2, 2 = tag_monoids -groupshared TagMonoid sh_parent[256]; +// Slots: 0 = config, 1 = scene, 2 = reduced (from scan1), 3 = tag_monoids groupshared TagMonoid sh_monoid[256]; [goldy_compute] [numthreads(256, 1, 1)] -void cs_main(BufRO reduced, BufRO reduced2, Scattered tag_monoids, +void cs_main(BufRO config_buf, BufRO scene, + BufRO reduced, Scattered tag_monoids, ThreadId global_id, GroupThreadId local_id, GroupId wg_id) { - - TagMonoid agg = TagMonoid.identity(); - if (local_id.x < wg_id.x) { - agg = reduced2[local_id.x]; - } - sh_parent[local_id.x] = agg; - for (uint i = 0; i < LG_WG_SIZE; i++) { - GroupMemoryBarrierWithGroupSync(); - if (local_id.x + (1u << i) < WG_SIZE) - agg = agg.combine(sh_parent[local_id.x + (1u << i)]); - GroupMemoryBarrierWithGroupSync(); - sh_parent[local_id.x] = agg; - } + Config config = config_buf[0]; uint ix = global_id.x; - agg = reduced[ix]; - sh_monoid[local_id.x] = agg; + uint tag_word = scene[config.pathtag_base + ix]; + TagMonoid agg_part = reduce_tag(tag_word); + sh_monoid[local_id.x] = agg_part; for (uint i = 0; i < LG_WG_SIZE; i++) { GroupMemoryBarrierWithGroupSync(); if (local_id.x >= (1u << i)) { TagMonoid other = sh_monoid[local_id.x - (1u << i)]; - agg = other.combine(agg); + agg_part = other.combine(agg_part); } GroupMemoryBarrierWithGroupSync(); - sh_monoid[local_id.x] = agg; + sh_monoid[local_id.x] = agg_part; } GroupMemoryBarrierWithGroupSync(); - TagMonoid tm = sh_parent[0]; + TagMonoid tm = reduced[wg_id.x]; if (local_id.x > 0) { tm = tm + sh_monoid[local_id.x - 1]; } diff --git a/ekrano_shaders/src/slang.rs b/ekrano_shaders/src/slang.rs index f1208119..d3222b8c 100644 --- a/ekrano_shaders/src/slang.rs +++ b/ekrano_shaders/src/slang.rs @@ -28,7 +28,7 @@ include_slang!(PATH_TILING_SETUP, "path_tiling_setup.slang"); include_slang!(PATH_TILING_SETUP_SCHEME, "path_tiling_setup_scheme.slang"); include_slang!(PATHTAG_REDUCE, "pathtag_reduce.slang"); include_slang!(PATHTAG_REDUCE2, "pathtag_reduce2.slang"); -include_slang!(PATHTAG_SCAN1, "pathtag_scan_large.slang"); // Same bindings as pathtag_scan1 (reduced, reduced2, tag_monoids) +include_slang!(PATHTAG_SCAN1, "pathtag_scan1.slang"); include_slang!(PATHTAG_SCAN_SMALL, "pathtag_scan_small.slang"); include_slang!(PATHTAG_SCAN_LARGE, "pathtag_scan_large.slang"); include_slang!(DRAW_REDUCE, "draw_reduce.slang"); diff --git a/ekrano_tests/snapshots/dashed_curves.png b/ekrano_tests/snapshots/dashed_curves.png new file mode 100644 index 00000000..cc6486fa --- /dev/null +++ b/ekrano_tests/snapshots/dashed_curves.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2c797303b999b49ac63a27e5b5333cff0e840cb2277fe48e588d19e91a29c75 +size 9965 diff --git a/ekrano_tests/tests/snapshot_test_scenes.rs b/ekrano_tests/tests/snapshot_test_scenes.rs index 9d0d086c..0c0d07da 100644 --- a/ekrano_tests/tests/snapshot_test_scenes.rs +++ b/ekrano_tests/tests/snapshot_test_scenes.rs @@ -95,6 +95,12 @@ fn snapshot_longpathdash_butt() { snapshot_test_scene(test_scene, params); } +fn snapshot_dashed_curves() { + let test_scene = test_scenes::dashed_curves(); + let params = TestParams::new("dashed_curves", 480, 240); + snapshot_test_scene(test_scene, params); +} + fn snapshot_image_sampling() { let test_scene = test_scenes::image_sampling(); let params = TestParams::new("image_sampling", 400, 400); @@ -220,6 +226,13 @@ fn main() { }) .with_ignored_flag(false), ); + trials.push( + libtest_mimic::Trial::test("snapshot_dashed_curves", || { + snapshot_dashed_curves(); + Ok(()) + }) + .with_ignored_flag(false), + ); trials.push( libtest_mimic::Trial::test("snapshot_image_sampling", || { snapshot_image_sampling(); diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index 01d48eb7..65e20c25 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -106,6 +106,7 @@ export_scenes!( fn clip_test(clip_test: animated) fn longpathdash_butt(impls::longpathdash(Cap::Butt), "longpathdash (butt caps)", false) fn longpathdash_round(impls::longpathdash(Cap::Round), "longpathdash (round caps)", false) + fn dashed_curves(dashed_curves) fn mmark(crate::mmark::MMark::new(80_000), "mmark", false) fn many_draw_objects(many_draw_objects) fn blurred_rounded_rect(blurred_rounded_rect) @@ -663,6 +664,38 @@ mod impls { } } + /// Curved dashed strokes — guards against treating Béziers as chords when dashing. + /// + /// A GPU chord-dash path turns these circles/ellipses into rotated polygons. + pub(super) fn dashed_curves(scene: &mut Scene, _params: &mut SceneParams<'_>) { + let dash = Stroke::new(6.0) + .with_caps(Cap::Butt) + .with_join(Join::Miter) + .with_dashes(0.0, [14.0, 10.0]); + scene.stroke( + &dash, + Affine::IDENTITY, + palette::css::WHITE, + None, + &Circle::new((120.0, 120.0), 80.0), + ); + scene.stroke( + &dash, + Affine::IDENTITY, + palette::css::DEEP_SKY_BLUE, + None, + &Ellipse::new((340.0, 120.0), (110.0, 60.0), 0.35), + ); + // Nested smaller circle — different dash phase / radius. + scene.stroke( + &Stroke::new(3.0).with_caps(Cap::Round).with_dashes(4.0, [8.0, 6.0]), + Affine::IDENTITY, + palette::css::ORANGE, + None, + &Circle::new((120.0, 120.0), 40.0), + ); + } + pub(super) fn animated_text(scene: &mut Scene, params: &mut SceneParams<'_>) { // Uses the static array address as a cache key for expedience. Real code // should use a better strategy. From 7f8e7184e5c0b117a9dff4a155e250d018175591 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa <11414628+koubaa@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:13:45 -0500 Subject: [PATCH 2/7] fix style --- ekrano/src/scheme_render.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/ekrano/src/scheme_render.rs b/ekrano/src/scheme_render.rs index 098c0801..105ce7aa 100644 --- a/ekrano/src/scheme_render.rs +++ b/ekrano/src/scheme_render.rs @@ -108,7 +108,6 @@ pub struct CapturedBuffers { /// Flatten uses a single dispatch (`ConfigUniform::flatten_thread_base` stays 0). /// Chunking via push-constant `thread_base` under-counted work on DX12. - fn dispatch_stage( recorder: &mut SchemeRecorder<'_>, indirect: &Buffer, From 1f5b625cc0eee7e5925f986f579001a70ca38ccf Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa <11414628+koubaa@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:34:52 -0500 Subject: [PATCH 3/7] ci: run Linux GPU tests on lavapipe instead of linux-gpu Paid org GPU runners are unavailable after the move to koubaa; use free ubuntu-24.04 with the existing lavapipe setup script. Co-authored-by: Cursor --- .github/workflows/ci.yml | 26 +++++++++++--------------- ci/setup-ubuntu.sh | 2 ++ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 668038c5..cf17c5ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,8 +178,10 @@ jobs: # TODO: re-enable windows-latest when Windows CI is green again os: [macos-latest, ubuntu-24.04] include: + # Linux: lavapipe (software Vulkan) on free ubuntu-24.04 runners. + # Requires Mesa 25.0+ via ci/setup-ubuntu.sh for Vulkan 1.4. - os: ubuntu-24.04 - runner: linux-gpu + runner: ubuntu-24.04 gpu: 'yes' - os: macos-latest runner: macos-latest @@ -225,22 +227,17 @@ jobs: if: matrix.os == 'ubuntu-24.04' run: bash ci/setup-ubuntu.sh - - name: Print Vulkan GPU information + - name: Verify lavapipe if: matrix.os == 'ubuntu-24.04' run: | - nvidia-smi - vulkaninfo --summary - - NVIDIA_VK_ICD="$(find /usr /etc -path '*/vulkan/icd.d/nvidia_icd*.json' -print -quit 2>/dev/null)" - if [ -z "$NVIDIA_VK_ICD" ]; then - echo "ERROR: NVIDIA Vulkan ICD not found" >&2 + if [ -z "${LAVAPIPE_ICD:-}" ]; then + echo "ERROR: LAVAPIPE_ICD not set by ci/setup-ubuntu.sh" >&2 exit 1 fi + echo "Using lavapipe ICD: $LAVAPIPE_ICD" + VK_ICD_FILENAMES="$LAVAPIPE_ICD" vulkaninfo --summary + VK_ICD_FILENAMES="$LAVAPIPE_ICD" vulkaninfo --summary | grep -Ei 'deviceName.*(llvmpipe|lavapipe)' - echo "NVIDIA_VK_ICD=$NVIDIA_VK_ICD" >> "$GITHUB_ENV" - echo "Using NVIDIA Vulkan ICD: $NVIDIA_VK_ICD" - VK_ICD_FILENAMES="$NVIDIA_VK_ICD" vulkaninfo --summary - - name: restore cache uses: Swatinem/rust-cache@v2 with: @@ -311,9 +308,8 @@ jobs: # Skip tests that are too slow on software-emulated GPUs (lavapipe, WARP). # These still run locally on real hardware. EKRANO_CI_SKIP_SLOW: 'yes' - # Linux GPU runner: force the NVIDIA Vulkan ICD so tests do not fall back to lavapipe. - # Other platforms leave this empty, which is harmless for the current test matrix. - VK_ICD_FILENAMES: ${{ env.NVIDIA_VK_ICD }} + # Linux: force lavapipe ICD (set by ci/setup-ubuntu.sh). Empty on macOS/Windows. + VK_ICD_FILENAMES: ${{ env.LAVAPIPE_ICD }} VK_LAYER_PATH: "" # We are experimenting with git lfs, and we don't expect to run out of bandwidth. # However, if we do, the tests are designed to be robust against that, if this environment variable is set. diff --git a/ci/setup-ubuntu.sh b/ci/setup-ubuntu.sh index a92e452b..8bc08d74 100644 --- a/ci/setup-ubuntu.sh +++ b/ci/setup-ubuntu.sh @@ -48,6 +48,8 @@ fi if [ -n "${GITHUB_ENV:-}" ]; then # Running inside GitHub Actions echo "LAVAPIPE_ICD=$LAVAPIPE_ICD" >> "$GITHUB_ENV" + echo "VK_ICD_FILENAMES=$LAVAPIPE_ICD" >> "$GITHUB_ENV" + echo "VK_LAYER_PATH=" >> "$GITHUB_ENV" echo "GOLDY_BACKEND=vulkan" >> "$GITHUB_ENV" else # Running in Docker or locally -- write to a sourceable env file From 66b1c715b7ba51854fe816a3edd62988ab37efe4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 02:46:03 +0000 Subject: [PATCH 4/7] Fix lavapipe snapshot failures from WG-divergent early returns Early `return` before GroupMemoryBarrierWithGroupSync in backdrop_dyn (and sibling shaders) prevented the hillis-steele backdrop prefix scan from completing on lavapipe, leaving interior tiles with backdrop=0 so coarse skipped solid fills. Gate work on failed==0 and only return after barrier-heavy sections (or when the exit is workgroup-uniform). Co-authored-by: Mohamed Koubaa --- ekrano_shaders/slang/backdrop_dyn.slang | 17 ++++++++++----- ekrano_shaders/slang/binning.slang | 28 ++++++++++++------------- ekrano_shaders/slang/coarse.slang | 5 +++-- ekrano_shaders/slang/tile_alloc.slang | 11 ++++++---- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/ekrano_shaders/slang/backdrop_dyn.slang b/ekrano_shaders/slang/backdrop_dyn.slang index 40a6ce14..aea0a349 100644 --- a/ekrano_shaders/slang/backdrop_dyn.slang +++ b/ekrano_shaders/slang/backdrop_dyn.slang @@ -13,6 +13,7 @@ static const uint LG_WG_SIZE = 8; groupshared uint sh_row_width[256]; groupshared uint sh_row_count[256]; groupshared uint sh_offset[256]; +groupshared uint sh_failed; // Slots: 0=config, 1=bump, 2=paths, 3=tiles @@ -23,17 +24,19 @@ void cs_main(BufRO config_buf, Scattered bump, ThreadId global_id, GroupThreadId local_id) { Config config = config_buf[0]; + // Broadcast bump.failed without an early `return`. Returning from only some + // threads before GroupMemoryBarrierWithGroupSync deadlocks the workgroup on + // lavapipe (observed: hillis-steele scan never completes; interior tiles keep + // raw path_count backdrops and coarse skips solid fills). if (local_id.x == 0) { - sh_row_count[0] = bump[0].failed; + sh_failed = bump[0].failed; } GroupMemoryBarrierWithGroupSync(); - uint failed = sh_row_count[0]; - if (failed != 0) - return; + uint failed = sh_failed; uint drawobj_ix = global_id.x; uint row_count = 0; - if (drawobj_ix < config.n_drawobj) { + if (failed == 0 && drawobj_ix < config.n_drawobj) { Path path = paths[drawobj_ix]; sh_row_width[local_id.x] = path.bbox.z - path.bbox.x; row_count = path.bbox.w - path.bbox.y; @@ -53,6 +56,10 @@ void cs_main(BufRO config_buf, Scattered bump, } GroupMemoryBarrierWithGroupSync(); + if (failed != 0) { + return; + } + uint total_rows = sh_row_count[WG_SIZE - 1]; for (uint row = local_id.x; row < total_rows; row += WG_SIZE) { uint el_ix = workgroup_upper_bound(row, sh_row_count); diff --git a/ekrano_shaders/slang/binning.slang b/ekrano_shaders/slang/binning.slang index ab1f7f5d..c18a121f 100644 --- a/ekrano_shaders/slang/binning.slang +++ b/ekrano_shaders/slang/binning.slang @@ -39,25 +39,23 @@ void cs_main(BufRO config_buf, BufRO scene, } GroupMemoryBarrierWithGroupSync(); uint failed = sh_previous_failed; - if (failed != 0) { - if (global_id.x == 0) { - // Diagnostic: on STAGE_FLATTEN overflow, stash the GPU-observed - // lines/lines_size into the unused binning/ptcl counters so the - // CPU retry path can compare against its own values. If the CPU - // thinks `config.lines_size` was 2M but the GPU reports 0, the - // buffer write never reached the GPU (was a bindless-slot - // aliasing bug). Uncomment to re-enable next time the flatten - // retry cascade reappears. - // bump[0].binning = bump[0].lines; - // bump[0].ptcl = config.lines_size; - InterlockedOr(bump[0].failed, STAGE_FLATTEN); - } - return; + // Do not early-return before later workgroup barriers (lavapipe deadlock). + if (failed != 0 && global_id.x == 0) { + // Diagnostic: on STAGE_FLATTEN overflow, stash the GPU-observed + // lines/lines_size into the unused binning/ptcl counters so the + // CPU retry path can compare against its own values. If the CPU + // thinks `config.lines_size` was 2M but the GPU reports 0, the + // buffer write never reached the GPU (was a bindless-slot + // aliasing bug). Uncomment to re-enable next time the flatten + // retry cascade reappears. + // bump[0].binning = bump[0].lines; + // bump[0].ptcl = config.lines_size; + InterlockedOr(bump[0].failed, STAGE_FLATTEN); } uint element_ix = global_id.x; int x0 = 0, y0 = 0, x1 = 0, y1 = 0; - if (element_ix < config.n_drawobj) { + if (failed == 0 && element_ix < config.n_drawobj) { DrawMonoid draw_monoid = draw_monoids[element_ix]; uint draw_tag = scene[config.drawtag_base + element_ix]; float4 clip_bbox = Bbox2D.infinite().v; diff --git a/ekrano_shaders/slang/coarse.slang b/ekrano_shaders/slang/coarse.slang index 4d6bea43..7d5b964e 100644 --- a/ekrano_shaders/slang/coarse.slang +++ b/ekrano_shaders/slang/coarse.slang @@ -153,16 +153,17 @@ void cs_main(BufRO config_buf, BufRO scene, } GroupMemoryBarrierWithGroupSync(); uint failed = sh_part_count[0]; + // Do not early-return before later workgroup barriers (lavapipe deadlock). if (failed != 0) { if (wg_id.x == 0 && local_id.x == 0) InterlockedOr(bump[0].failed, failed); - return; } uint width_in_bins = (config.width_in_tiles + N_TILE_X - 1) / N_TILE_X; uint bin_ix = width_in_bins * wg_id.y + wg_id.x; // Skip when bin_ix exceeds bin_headers capacity (binning_wgs * 256). See vello #680. - if (bin_ix >= 256) + // `bin_ix` is uniform across the workgroup, so this return is WG-uniform. + if (failed != 0 || bin_ix >= 256) return; uint n_partitions = (config.n_drawobj + N_TILE - 1) / N_TILE; diff --git a/ekrano_shaders/slang/tile_alloc.slang b/ekrano_shaders/slang/tile_alloc.slang index 050b1d20..05b4e03a 100644 --- a/ekrano_shaders/slang/tile_alloc.slang +++ b/ekrano_shaders/slang/tile_alloc.slang @@ -24,18 +24,18 @@ void cs_main(BufRO config_buf, BufRO scene, BufRO draw_bbo ThreadId global_id, GroupThreadId local_id) { Config config = config_buf[0]; + // Do not early-return before workgroup barriers: divergent returns deadlock + // the hillis-steele scan on lavapipe (same issue as backdrop_dyn). if (local_id.x == 0) { uint failed_val = bump[0].failed & (STAGE_BINNING | STAGE_FLATTEN); sh_previous_failed = (failed_val != 0) ? 1u : 0u; } GroupMemoryBarrierWithGroupSync(); uint failed = sh_previous_failed; - if (failed != 0) - return; uint drawobj_ix = global_id.x; uint drawtag = DRAWTAG_NOP; - if (drawobj_ix < config.n_drawobj) { + if (failed == 0 && drawobj_ix < config.n_drawobj) { drawtag = scene[config.drawtag_base + drawobj_ix]; } int x0 = 0, y0 = 0, x1 = 0, y1 = 0; @@ -55,7 +55,7 @@ void cs_main(BufRO config_buf, BufRO scene, BufRO draw_bbo // Morton layout: allocate a power-of-two square so that // morton_encode_2d(x, y) is always a valid index within the allocation. // Trade-off: up to 4× memory for highly non-square bboxes. - uint mdim = morton_tile_dim(ux1 - ux0, uy1 - uy0); + uint mdim = (failed == 0) ? morton_tile_dim(ux1 - ux0, uy1 - uy0) : 0u; uint tile_count = mdim * mdim; sh_tile_count[local_id.x] = tile_count; for (uint i = 0; i < LG_WG_SIZE; i++) { @@ -67,6 +67,9 @@ void cs_main(BufRO config_buf, BufRO scene, BufRO draw_bbo GroupMemoryBarrierWithGroupSync(); sh_tile_count[local_id.x] = tile_count; } + if (failed != 0) { + return; + } uint total_tile_count = tile_count; if (local_id.x == WG_SIZE - 1) { uint count = sh_tile_count[WG_SIZE - 1]; From d452fafd15340cc4cb5bd8fef76abdc9d14323c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 11:21:21 +0000 Subject: [PATCH 5/7] Restore texel Load bilinear for cross-backend image sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware SampleLevel bilinear differs by ±1–2 between Metal and lavapipe; luminance-mask scenes amplify that past the 0.0095 FLIP budget. Snapshots still reflect Vello's explicit 4-tap path — restore that for image atlas reads (atlas remains CPU-premultiplied). Co-authored-by: Mohamed Koubaa --- ekrano/src/scheme_gpu_resources.rs | 8 ++++---- ekrano_shaders/slang/fine.slang | 32 +++++++++++++++++------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/ekrano/src/scheme_gpu_resources.rs b/ekrano/src/scheme_gpu_resources.rs index 59674188..e9e4b44f 100644 --- a/ekrano/src/scheme_gpu_resources.rs +++ b/ekrano/src/scheme_gpu_resources.rs @@ -223,10 +223,10 @@ pub(crate) fn write_image_region( } let raw_bytes = image_data.data.data(); - // The atlas is always sampled with hardware bilinear, which requires premultiplied-alpha - // texels to avoid fringing on transparent edges. Straight-alpha images (ImageAlphaType::Alpha) - // are converted to premultiplied on the CPU before upload; premultiplied sources are used - // as-is. Callers' ImageData is never mutated. + // Fine samples the atlas with an explicit 4-tap bilinear (or nearest Load), which still + // requires premultiplied-alpha texels to avoid fringing on transparent edges. + // Straight-alpha images (ImageAlphaType::Alpha) are converted to premultiplied on the CPU + // before upload; premultiplied sources are used as-is. Callers' ImageData is never mutated. let premul_storage; let bytes: &[u8] = if image_data.alpha_type == peniko::ImageAlphaType::Alpha { premul_storage = premultiply_rgba8(raw_bytes); diff --git a/ekrano_shaders/slang/fine.slang b/ekrano_shaders/slang/fine.slang index b720182d..fe717382 100644 --- a/ekrano_shaders/slang/fine.slang +++ b/ekrano_shaders/slang/fine.slang @@ -932,21 +932,21 @@ void cs_main(BufRO config_buf, BufRO segments, } else if (tag == CMD_IMAGE) { CmdImage image = read_image_cmd(ptcl, info, cmd_ix); float2 atlas_max = image.atlas_offset + image.extents - 1.0; - // Clamp bounds for hardware bilinear: keep sampling 0.5px inside each edge so the - // hardware bilinear footprint never bleeds into adjacent atlas images. - float2 norm_min = (image.atlas_offset + 0.5) * inv_atlas; - float2 norm_max = (image.atlas_offset + image.extents - 0.5) * inv_atlas; + // Atlas texels are CPU-premultiplied before upload. Sample with explicit + // texel Loads (not hardware bilinear): Metal vs lavapipe linear filtering + // differs by ±1–2 in 8-bit, and luminance-mask scenes amplify that past + // the FLIP budget against Vello-era references that used this 4-tap path. if (image.quality == IMAGE_QUALITY_LOW) { - // Nearest-neighbor via hardware sampler (atlas stores premultiplied RGBA). + // Nearest neighbor (matches original Vello fine shader IMAGE_QUALITY_LOW) for (uint i = 0; i < 4; i++) { if (area[i] != 0.0) { float2 my_xy = float2(xy.x + float(i), xy.y); float2 atlas_uv = image.xform * my_xy; atlas_uv.x = extend_mode(atlas_uv.x, image.x_extend_mode, image.extents.x); atlas_uv.y = extend_mode(atlas_uv.y, image.y_extend_mode, image.extents.y); - atlas_uv = floor(atlas_uv) + image.atlas_offset; - float2 norm_uv = clamp((atlas_uv + 0.5) * inv_atlas, norm_min, norm_max); - float4 fg_rgba = image_atlas.SampleLevel(nearest_clamp, norm_uv, 0); + atlas_uv += image.atlas_offset; + float2 atlas_uv_clamped = clamp(atlas_uv, image.atlas_offset, atlas_max); + float4 fg_rgba = image_atlas.Load(int3(int2(atlas_uv_clamped), 0)); float4 fg_i = pixel_format(fg_rgba * area[i] * mask_w[i] * image.alpha, image.format); uint BLEND_DEFAULT_IL = (MIX_NORMAL << 8) | COMPOSE_SRC_OVER; rgba[i] = ((draw_blend & 0x7FFFu) == BLEND_DEFAULT_IL) @@ -955,19 +955,23 @@ void cs_main(BufRO config_buf, BufRO segments, } } } else { - // Bilinear via hardware sampler (atlas stores premultiplied RGBA; 4× fewer - // texture ops vs the manual 4-tap path this replaced). + // Bilinear (matches original Vello fine shader IMAGE_QUALITY_MEDIUM) for (uint i = 0; i < 4; i++) { if (area[i] != 0.0) { float2 my_xy = float2(xy.x + float(i), xy.y); float2 atlas_uv = image.xform * my_xy; atlas_uv.x = extend_mode(atlas_uv.x, image.x_extend_mode, image.extents.x); atlas_uv.y = extend_mode(atlas_uv.y, image.y_extend_mode, image.extents.y); - // +atlas_offset - 0.5: shift to atlas pixel space; -0.5 aligns with - // hardware bilinear convention (sample at sub-texel position). atlas_uv += image.atlas_offset - 0.5; - float2 norm_uv = clamp((atlas_uv + 0.5) * inv_atlas, norm_min, norm_max); - float4 fg_rgba = image_atlas.SampleLevel(linear_clamp, norm_uv, 0); + float2 atlas_uv_clamped = clamp(atlas_uv, image.atlas_offset, atlas_max); + int2 uv00 = int2(floor(atlas_uv_clamped)); + int2 uv11 = int2(ceil(atlas_uv_clamped)); + float2 uv_frac = frac(atlas_uv); + float4 a = image_atlas.Load(int3(uv00, 0)); + float4 b = image_atlas.Load(int3(uv00.x, uv11.y, 0)); + float4 c = image_atlas.Load(int3(uv11.x, uv00.y, 0)); + float4 d = image_atlas.Load(int3(uv11, 0)); + float4 fg_rgba = lerp(lerp(a, b, uv_frac.y), lerp(c, d, uv_frac.y), uv_frac.x); float4 fg_i = pixel_format(fg_rgba * area[i] * mask_w[i] * image.alpha, image.format); uint BLEND_DEFAULT_IM = (MIX_NORMAL << 8) | COMPOSE_SRC_OVER; rgba[i] = ((draw_blend & 0x7FFFu) == BLEND_DEFAULT_IM) From 1e386f4eb78a7cb65dceee3fea5407643623aa9e Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa <11414628+koubaa@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:33:04 -0500 Subject: [PATCH 6/7] Enhance CI configuration to support Windows testing and update feature flags - Re-enabled Windows testing in the CI workflow, adding specific test commands for Windows with DX12 support. - Updated Cargo.toml files across multiple projects to include new feature flags for goldy backends, allowing for more granular control over dependencies. - Set default features to false for dependencies to ensure compatibility and reduce unnecessary bloat during builds. Co-authored-by: Mohamed Koubaa --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- ekrano/Cargo.toml | 7 +++++-- ekrano_tests/Cargo.toml | 18 +++++++++++++++--- examples/headless/Cargo.toml | 18 +++++++++++++++--- examples/scenes/Cargo.toml | 7 ++++++- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf17c5ec..d863fa2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,8 +175,6 @@ jobs: strategy: fail-fast: false matrix: - # TODO: re-enable windows-latest when Windows CI is green again - os: [macos-latest, ubuntu-24.04] include: # Linux: lavapipe (software Vulkan) on free ubuntu-24.04 runners. # Requires Mesa 25.0+ via ci/setup-ubuntu.sh for Vulkan 1.4. @@ -186,6 +184,9 @@ jobs: - os: macos-latest runner: macos-latest gpu: 'yes' + - os: windows-latest + runner: windows-latest + gpu: 'yes' steps: - uses: actions/checkout@v4 # We intentionally do not use lfs: true here, instead using the caching method to save LFS bandwidth. @@ -302,6 +303,7 @@ jobs: - name: cargo test # TODO: Maybe use --release; the CPU shaders are extremely slow when unoptimised # One process per test binary enables process-lifetime shared Device + WARP mutex. + if: matrix.os != 'windows-latest' run: cargo test --workspace --locked --all-features --no-fail-fast env: EKRANO_CI_GPU_SUPPORT: ${{ matrix.gpu }} @@ -315,6 +317,19 @@ jobs: # However, if we do, the tests are designed to be robust against that, if this environment variable is set. # If we do run out of bandwidth, uncomment the following line. # EKRANO_SKIP_LFS_SNAPSHOTS: all + + - name: cargo test (Windows / DX12-only Goldy) + if: matrix.os == 'windows-latest' + run: >- + cargo test --workspace --locked + --no-default-features + -F goldy-dx12-only -F bump_estimate -F tracy + --no-fail-fast + env: + EKRANO_CI_GPU_SUPPORT: ${{ matrix.gpu }} + EKRANO_CI_SKIP_SLOW: 'yes' + GOLDY_BACKEND: dx12 + VK_LAYER_PATH: "" - name: Upload test results due to failure uses: actions/upload-artifact@v4 @@ -325,8 +340,17 @@ jobs: ekrano_tests/current - name: cargo test --doc + if: matrix.os != 'windows-latest' run: cargo test --doc --workspace --locked --all-features --no-fail-fast + - name: cargo test --doc (Windows / DX12-only Goldy) + if: matrix.os == 'windows-latest' + run: >- + cargo test --doc --workspace --locked + --no-default-features + -F goldy-dx12-only -F bump_estimate -F tracy + --no-fail-fast + check-msrv: name: cargo check (msrv) runs-on: ${{ matrix.os }} diff --git a/ekrano/Cargo.toml b/ekrano/Cargo.toml index feeb8401..e1d7b1cd 100644 --- a/ekrano/Cargo.toml +++ b/ekrano/Cargo.toml @@ -16,7 +16,10 @@ default-target = "x86_64-unknown-linux-gnu" targets = [] [features] -default = [] +default = ["goldy-default"] +# Mirrors `goldy` default backends for local dev; CI can pass `--no-default-features -F goldy-dx12-only`. +goldy-default = ["goldy/vulkan", "goldy/dx12", "goldy/metal", "goldy/instrumentation"] +goldy-dx12-only = ["goldy/dx12"] # Enables GPU memory usage estimation. This performs additional computations # in order to estimate the minimum required allocations for buffers backing # bump-allocated GPU memory. @@ -46,4 +49,4 @@ static_assertions = { workspace = true } thiserror = { workspace = true } # TODO: Add feature for built-in bitmap emoji support? png = { workspace = true } -goldy = { path = "../../goldy" } +goldy = { path = "../../goldy", default-features = false } diff --git a/ekrano_tests/Cargo.toml b/ekrano_tests/Cargo.toml index f8ff03cd..63b2d6fe 100644 --- a/ekrano_tests/Cargo.toml +++ b/ekrano_tests/Cargo.toml @@ -8,12 +8,24 @@ license.workspace = true repository.workspace = true publish = false +[features] +default = ["goldy-default"] +goldy-default = [ + "ekrano/goldy-default", + "scenes/goldy-default", + "goldy/vulkan", + "goldy/dx12", + "goldy/metal", + "goldy/instrumentation", +] +goldy-dx12-only = ["ekrano/goldy-dx12-only", "scenes/goldy-dx12-only", "goldy/dx12"] + [lints] workspace = true [dependencies] -ekrano = { path = "../ekrano" } -goldy = { path = "../../goldy" } +ekrano = { path = "../ekrano", default-features = false } +goldy = { path = "../../goldy", default-features = false } anyhow = { workspace = true } pollster = { workspace = true } @@ -21,7 +33,7 @@ png = { workspace = true } nv-flip = "0.1.2" image = { workspace = true, features = ["png"] } -scenes = { workspace = true } +scenes = { path = "../examples/scenes", default-features = false } oxipng = { workspace = true, features = ["freestanding", "parallel"] } env_logger = { workspace = true } log = { workspace = true } diff --git a/examples/headless/Cargo.toml b/examples/headless/Cargo.toml index 532f8487..d09d49cf 100644 --- a/examples/headless/Cargo.toml +++ b/examples/headless/Cargo.toml @@ -6,13 +6,25 @@ license.workspace = true repository.workspace = true publish = false +[features] +default = ["goldy-default"] +goldy-default = [ + "ekrano/goldy-default", + "scenes/goldy-default", + "goldy/vulkan", + "goldy/dx12", + "goldy/metal", + "goldy/instrumentation", +] +goldy-dx12-only = ["ekrano/goldy-dx12-only", "scenes/goldy-dx12-only", "goldy/dx12"] + [lints] workspace = true [dependencies] -ekrano = { path = "../../ekrano" } -goldy = { path = "../../../goldy" } -scenes = { workspace = true } +ekrano = { path = "../../ekrano", default-features = false } +goldy = { path = "../../../goldy", default-features = false } +scenes = { path = "../scenes", default-features = false } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/examples/scenes/Cargo.toml b/examples/scenes/Cargo.toml index bce25d19..dafac0e8 100644 --- a/examples/scenes/Cargo.toml +++ b/examples/scenes/Cargo.toml @@ -6,11 +6,16 @@ license.workspace = true repository.workspace = true publish = false +[features] +default = ["goldy-default"] +goldy-default = ["ekrano/goldy-default"] +goldy-dx12-only = ["ekrano/goldy-dx12-only"] + [lints] workspace = true [dependencies] -ekrano = { workspace = true } +ekrano = { path = "../../ekrano", default-features = false } skrifa = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } From a826da165e46fd84bd05f917d3c6bbfaaf7016c8 Mon Sep 17 00:00:00 2001 From: Mohamed Koubaa <11414628+koubaa@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:44:15 -0500 Subject: [PATCH 7/7] Update CI configuration to temporarily disable Windows testing - Added comments indicating the need to re-enable Windows testing when the CI is stable. - Removed Windows-specific test commands from the CI matrix for now. - Marked steps for Windows testing as ready for re-integration once conditions are met. Co-authored-by: Mohamed Koubaa --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d863fa2c..7e8b324c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,8 @@ jobs: strategy: fail-fast: false matrix: + # TODO: re-enable windows-latest when Windows CI is green again. + # When enabling, add a windows-latest row below and use the DX12-only Goldy test steps. include: # Linux: lavapipe (software Vulkan) on free ubuntu-24.04 runners. # Requires Mesa 25.0+ via ci/setup-ubuntu.sh for Vulkan 1.4. @@ -184,9 +186,6 @@ jobs: - os: macos-latest runner: macos-latest gpu: 'yes' - - os: windows-latest - runner: windows-latest - gpu: 'yes' steps: - uses: actions/checkout@v4 # We intentionally do not use lfs: true here, instead using the caching method to save LFS bandwidth. @@ -303,7 +302,6 @@ jobs: - name: cargo test # TODO: Maybe use --release; the CPU shaders are extremely slow when unoptimised # One process per test binary enables process-lifetime shared Device + WARP mutex. - if: matrix.os != 'windows-latest' run: cargo test --workspace --locked --all-features --no-fail-fast env: EKRANO_CI_GPU_SUPPORT: ${{ matrix.gpu }} @@ -318,6 +316,7 @@ jobs: # If we do run out of bandwidth, uncomment the following line. # EKRANO_SKIP_LFS_SNAPSHOTS: all + # Ready when windows-latest is re-added to the matrix above. - name: cargo test (Windows / DX12-only Goldy) if: matrix.os == 'windows-latest' run: >- @@ -340,9 +339,9 @@ jobs: ekrano_tests/current - name: cargo test --doc - if: matrix.os != 'windows-latest' run: cargo test --doc --workspace --locked --all-features --no-fail-fast + # Ready when windows-latest is re-added to the matrix above. - name: cargo test --doc (Windows / DX12-only Goldy) if: matrix.os == 'windows-latest' run: >-