diff --git a/crates/pf-zerocopy/src/imp/cursor_blend.comp b/crates/pf-zerocopy/src/imp/cursor_blend.comp index 41aa8479..5edb7795 100644 --- a/crates/pf-zerocopy/src/imp/cursor_blend.comp +++ b/crates/pf-zerocopy/src/imp/cursor_blend.comp @@ -15,7 +15,9 @@ // invocation exclusively owns the 32-bit words it read-modify-writes. ARGB: one invocation per // cursor pixel = one word. NV12/YUV444: one invocation per WORD-ALIGNED 4-px luma span (per two // rows for NV12, whose 2 chroma bytes-pairs land in one exclusive word). Spans are aligned to the -// SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`. +// SURFACE, not the cursor, so neighbouring invocations never share a word even at odd `ox`; NV12 +// block rows are likewise anchored to the surface chroma grid (even rows), so each UV sample's +// 2x2 footprint is exactly the luma rows it averages, at any `oy`. // // Rebuild: glslangValidator -V cursor_blend.comp -o cursor_blend.spv (vendored beside this // file; or glslc — CI diffs the disassembly against this source) @@ -112,15 +114,20 @@ void main() { return; } - // NV12: rows walk 2-row luma blocks (row = block row). The span's 4 luma px × 2 rows are - // exclusive words; its 2 chroma samples (4 bytes) are one exclusive word. - int base_cy = row * 2; - if (base_cy >= int(pc.curH)) return; - // Luma: 4 px × 2 rows. + // NV12: rows walk 2-row luma blocks anchored to the SURFACE chroma grid (block top = an even + // surface row), the same way spans are anchored to the surface word grid in x. Anchoring to + // the cursor's oy instead put every chroma sample one luma row high whenever oy was odd — + // and never wrote the cursor's last row's chroma at all. y0 = floor(oy/2)*2; the arithmetic + // shift keeps that floor for negative oy. blend_geometry counts blocks from this same anchor. + int y0 = (pc.oy >> 1) << 1; + int py_top = y0 + row * 2; // this block's top luma row (even by construction) + if (py_top >= pc.oy + int(pc.curH)) return; // dispatch-padding block below the cursor + // Luma: 4 px × 2 rows. The first block can start one row above the cursor (odd oy) — the + // per-row cy guard skips that row. for (int j = 0; j < 2; j++) { - int cy = base_cy + j; - int py = pc.oy + cy; - if (cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue; + int py = py_top + j; + int cy = py - pc.oy; + if (cy < 0 || cy >= int(pc.curH) || py < 0 || py >= int(pc.surfH)) continue; for (int i = 0; i < 4; i++) { int px = px0 + i; int cx = px - pc.ox; @@ -132,11 +139,12 @@ void main() { } } // Chroma: two UV samples covering the span's 2x2 blocks, alpha-weighted like the .cu kernel. - // The UV plane starts at row surfH; sample (uvx, uvy) lives at uv_base + uvy*pitch + uvx*2. + // Because py_top sits on the chroma grid, sample uvy covers exactly luma rows py_top and + // py_top+1 — the rows averaged below. The UV plane starts at row surfH; sample (uvx, uvy) + // lives at uv_base + uvy*pitch + uvx*2. // Guard: only spans whose px0 is 4-aligned own their chroma word (px0 is by construction). - int py_top = pc.oy + base_cy; int uvy = py_top >> 1; - if (py_top < 0 || uvy < 0 || uvy * 2 >= int(pc.surfH)) return; + if (py_top < 0 || uvy * 2 >= int(pc.surfH)) return; uint uv_base = pc.pitch * pc.surfH; for (int hf = 0; hf < 2; hf++) { // Each hf = one 2x2 luma block = one UV sample (2 bytes). @@ -150,7 +158,7 @@ void main() { int px = bx + i; int py = py_top + j; int cx = px - pc.ox; - int cy = base_cy + j; + int cy = py - pc.oy; if (px < 0 || py < 0 || px >= int(pc.surfW) || py >= int(pc.surfH)) continue; uvec4 s = cursor_px(cx, cy); if (s.a == 0u) continue; diff --git a/crates/pf-zerocopy/src/imp/cursor_blend.spv b/crates/pf-zerocopy/src/imp/cursor_blend.spv index 046d149a..f9b9604e 100644 Binary files a/crates/pf-zerocopy/src/imp/cursor_blend.spv and b/crates/pf-zerocopy/src/imp/cursor_blend.spv differ diff --git a/crates/pf-zerocopy/src/imp/vkslot.rs b/crates/pf-zerocopy/src/imp/vkslot.rs index 5f369a4e..187826b6 100644 --- a/crates/pf-zerocopy/src/imp/vkslot.rs +++ b/crates/pf-zerocopy/src/imp/vkslot.rs @@ -660,10 +660,11 @@ impl VkSlotBlend { pub fn free_slots(&mut self) { // Ordered blends return with their work still on the queue — quiesce before freeing the // buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission - // whose CUDA copy-done signal never fired is still covered; this tiny device idles in - // microseconds outside that pathological case. CPU-synced blends need nothing (they - // fence-wait before returning), so a `None` timeline skips it. - if self.timeline.is_some() && !self.slots.is_empty() { + // whose CUDA copy-done signal never fired is still covered. CPU-synced blends normally + // fence-wait before returning, but a blend whose wait TIMED OUT propagated an error with + // its submission still executing — so the drain runs unconditionally (it idles in + // microseconds on this tiny device outside the pathological cases it exists for). + if !self.slots.is_empty() { // SAFETY: single-threaded owner; no other thread touches this device or its queue. unsafe { let _ = self.device.device_wait_idle(); @@ -773,7 +774,14 @@ impl VkSlotBlend { let x0 = (ox >> 2) << 2; let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32; let rows = match fmt { - SlotFormat::Nv12 => ch.div_ceil(2), + SlotFormat::Nv12 => { + // 2-row blocks anchored to the SURFACE chroma grid (cursor_blend.comp + // derives the same y0): count the blocks covering luma rows + // [oy, oy+ch) — one more than ch/2 when oy is odd. + let first = oy.div_euclid(2); + let last = (oy + ch as i32 - 1).div_euclid(2); + (last - first + 1) as u32 + } _ => ch, }; (spans.div_ceil(8), rows.div_ceil(8)) @@ -906,6 +914,14 @@ impl VkSlotBlend { d.queue_submit(self.queue, &submit, self.fence) .context("submit blend")?; let r = d.wait_for_fences(&[self.fence], true, 1_000_000_000); + if r.is_err() { + // TIMEOUT (or device loss): the submission may still be executing. Resetting a + // fence with a pending signal and re-recording this slot's command buffer next + // frame would race the GPU — drain the device first (what + // `VkBridge::import_linear` does on a failed wait). On this tiny device the + // idle completes in microseconds once the stall clears. + let _ = d.device_wait_idle(); + } d.reset_fences(&[self.fence]).ok(); r.context("blend fence wait")?; } @@ -1127,4 +1143,18 @@ mod tests { assert_eq!(push.ox, -5, "push constants carry the true origin"); assert_eq!(gx, 2, "9 spans from the floor-aligned start → 2 groups"); } + + /// NV12 block rows anchor to the SURFACE chroma grid, so an odd `oy` needs one extra block + /// to reach the cursor's last luma row (the cursor-anchored count left that row's chroma + /// unwritten). Negative odd `oy` floors the same way (`div_euclid`). + #[test] + fn odd_oy_nv12_adds_the_straddle_block() { + let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 8).unwrap(); + assert_eq!(gy, 2, "even oy: 16 chroma-grid blocks → 2 groups"); + let (_, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, 7).unwrap(); + assert_eq!(gy, 3, "odd oy: 17 blocks cover luma rows 7..39 → 3 groups"); + let (push, _, gy) = geo(SlotFormat::Nv12, 32, 32, 0, -3).unwrap(); + assert_eq!(push.oy, -3, "push constants carry the true origin"); + assert_eq!(gy, 3, "blocks -4..30 in steps of 2 → 17 → 3 groups"); + } }