feat(host/linux): cursor-as-metadata — pointer in gamescope streams, no perf hit
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 1m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
windows-host / package (push) Successful in 8m37s
ci / bench (push) Successful in 5m47s
ci / rust (push) Failing after 8m24s
docker / deploy-docs (push) Successful in 22s
arch / build-publish (push) Successful in 11m26s
android / android (push) Successful in 12m54s
deb / build-publish (push) Successful in 12m8s
apple / swift (push) Successful in 5m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m31s
apple / screenshots (push) Successful in 19m34s

gamescope draws its pointer on a hardware DRM cursor plane that never enters
the framebuffer feeding its PipeWire capture node, so captured frames arrive
cursorless. Rather than force the producer's Embedded full-frame composite,
request the pointer as PipeWire SPA_META_Cursor and composite it ourselves —
a ≤256×256 blit into the encoder-OWNED surface, never the compositor's
read-only dmabuf.

Capture (capture/linux/mod.rs, capture.rs):
- choose_cursor_mode() gates on available_cursor_modes(): Metadata > Embedded
  > Hidden (defaults Embedded on query error — never silently lose the cursor).
  Applied on both the plain and remote-desktop portal paths.
- build_cursor_meta_param() adds a SPA_PARAM_Meta pod requesting SPA_META_Cursor
  (bitmap up to 256x256) to the connect params on every path.
- CursorState parses spa_meta_cursor (id 0 = hidden; position - hotspot; bitmap
  re-read only when bitmap_offset != 0), normalizing RGBA/BGRA/ARGB/ABGR.
  Updated in .process before the corrupted/size-0 skip so cursor-only Mutter
  buffers still track movement.
- CapturedFrame gains cursor: Option<CursorOverlay> (Arc rgba + serial) riding
  the GPU (Dmabuf/Cuda) payloads; the CPU de-pad path composites inline.

GPU composite into each zero-copy backend's owned surface:
- Vulkan Video + PyroWave: folded into the shared rgb2yuv.comp CSC shader —
  cursor sampled and alpha-mixed over RGB before the YUV convert (correct
  chroma, no extra pass). binding 3 (combined image sampler) + 16B push
  constant, per-slot cursor image uploaded only on serial change. spv regenerated.
- CUDA/NVENC: real on-GPU kernel (cursor_blend.cu -> cursor_blend.ptx,
  compute_75 Turing baseline, JIT-forward) with blend_argb/blend_yuv444/
  blend_nv12 (BT.709 limited, matching the shader). Loaded via the hand-rolled
  libcuda fn-table; blended into the ring InputSurface after copy, degrading to
  no-cursor on any failure — never drops a frame.

VAAPI (AMD/Intel fallback) deferred: Vulkan Video already covers those GPUs;
blind libva struct-layout FFI shouldn't ship unverified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:19:55 +02:00
parent 694bec4ead
commit 5249d31dfa
15 changed files with 1853 additions and 15 deletions
@@ -0,0 +1,105 @@
// Cursor-overlay blend kernels for the CUDA/NVENC path (cursor-as-metadata). The cursor bitmap is
// straight-alpha RGBA, row-packed (stride = curW*4). Blended into the encoder-OWNED NVENC input
// surface — never the compositor's dmabuf. One thread per cursor pixel (ARGB / YUV444) or per 2x2
// chroma block (NV12). Coefficients are BT.709 limited, matching rgb2yuv.comp so the cursor colour
// matches the rest of the frame regardless of which zero-copy backend encodes it.
//
// Build (regenerate cursor_blend.ptx after editing):
// nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx
// PTX is JIT'd by the driver forward to the actual GPU, so a compute_75 (Turing) baseline runs on
// every Turing-or-newer NVENC GPU. (CUDA 13's nvcc no longer targets pre-Turing archs.)
typedef unsigned char u8;
__device__ __forceinline__ u8 blend8(int dst, int src, int a) {
return (u8)((src * a + dst * (255 - a)) / 255);
}
// Packed 4-byte surface. NVENC's ARGB format stores bytes B,G,R,A in memory; the cursor is R,G,B,A.
extern "C" __global__ void blend_argb(
u8* surf, int pitch, int surfW, int surfH,
const u8* cur, int curW, int curH, int ox, int oy)
{
int cx = blockIdx.x * blockDim.x + threadIdx.x;
int cy = blockIdx.y * blockDim.y + threadIdx.y;
if (cx >= curW || cy >= curH) return;
int px = ox + cx, py = oy + cy;
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
int a = s[3];
if (a == 0) return;
u8* d = surf + (size_t)py * pitch + (size_t)px * 4;
d[0] = blend8(d[0], s[2], a); // B <- cursor B
d[1] = blend8(d[1], s[1], a); // G <- cursor G
d[2] = blend8(d[2], s[0], a); // R <- cursor R
}
// Planar YUV444: three full-res planes stacked at base, base+plane, base+2*plane (plane=pitch*surfH).
extern "C" __global__ void blend_yuv444(
u8* base, int pitch, int surfW, int surfH,
const u8* cur, int curW, int curH, int ox, int oy)
{
int cx = blockIdx.x * blockDim.x + threadIdx.x;
int cy = blockIdx.y * blockDim.y + threadIdx.y;
if (cx >= curW || cy >= curH) return;
int px = ox + cx, py = oy + cy;
if (px < 0 || py < 0 || px >= surfW || py >= surfH) return;
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
int a = s[3];
if (a == 0) return;
float R = s[0], G = s[1], B = s[2];
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
int U = (int)(128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B + 0.5f);
int V = (int)(128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B + 0.5f);
size_t plane = (size_t)pitch * surfH;
u8* yp = base + (size_t)py * pitch + px;
u8* up = base + plane + (size_t)py * pitch + px;
u8* vp = base + 2 * plane + (size_t)py * pitch + px;
*yp = blend8(*yp, Y, a);
*up = blend8(*up, U, a);
*vp = blend8(*vp, V, a);
}
// NV12: full-res Y plane + interleaved half-res UV plane. One thread per 2x2 luma block; each blends
// up to four Y samples and one (alpha-weighted) UV sample.
extern "C" __global__ void blend_nv12(
u8* yb, int yPitch, u8* uvb, int uvPitch, int surfW, int surfH,
const u8* cur, int curW, int curH, int ox, int oy)
{
int bx = blockIdx.x * blockDim.x + threadIdx.x;
int by = blockIdx.y * blockDim.y + threadIdx.y;
int base_cx = bx * 2, base_cy = by * 2;
if (base_cx >= curW || base_cy >= curH) return;
float ua = 0.0f, va = 0.0f, wa = 0.0f;
int cnt = 0;
for (int j = 0; j < 2; j++) {
for (int i = 0; i < 2; i++) {
int cx = base_cx + i, cy = base_cy + j;
if (cx >= curW || cy >= curH) continue;
int px = ox + cx, py = oy + cy;
if (px < 0 || py < 0 || px >= surfW || py >= surfH) continue;
const u8* s = cur + (size_t)(cy * curW + cx) * 4;
int a = s[3];
if (a == 0) continue;
float R = s[0], G = s[1], B = s[2];
int Y = (int)(16.0f + 0.1826f * R + 0.6142f * G + 0.0620f * B + 0.5f);
u8* yp = yb + (size_t)py * yPitch + px;
*yp = blend8(*yp, Y, a);
ua += (128.0f - 0.1006f * R - 0.3386f * G + 0.4392f * B) * a;
va += (128.0f + 0.4392f * R - 0.3989f * G - 0.0403f * B) * a;
wa += a;
cnt++;
}
}
if (wa <= 0.0f || cnt == 0) return;
// The chroma sample covering this block's top-left surface pixel.
int uvx = (ox + base_cx) / 2;
int uvy = (oy + base_cy) / 2;
if (uvx < 0 || uvy < 0 || uvx * 2 >= surfW || uvy * 2 >= surfH) return;
int U = (int)(ua / wa + 0.5f);
int V = (int)(va / wa + 0.5f);
int amean = (int)(wa / cnt + 0.5f);
u8* uv = uvb + (size_t)uvy * uvPitch + (size_t)uvx * 2;
uv[0] = blend8(uv[0], U, amean);
uv[1] = blend8(uv[1], V, amean);
}
@@ -0,0 +1,576 @@
//
// Generated by NVIDIA NVVM Compiler
//
// Compiler Build ID: CL-38244171
// Cuda compilation tools, release 13.3, V13.3.73
// Based on NVVM 7.0.1
//
.version 9.3
.target sm_75
.address_size 64
// .globl blend_argb
.visible .entry blend_argb(
.param .u64 blend_argb_param_0,
.param .u32 blend_argb_param_1,
.param .u32 blend_argb_param_2,
.param .u32 blend_argb_param_3,
.param .u64 blend_argb_param_4,
.param .u32 blend_argb_param_5,
.param .u32 blend_argb_param_6,
.param .u32 blend_argb_param_7,
.param .u32 blend_argb_param_8
)
{
.reg .pred %p<10>;
.reg .b16 %rs<2>;
.reg .b32 %r<34>;
.reg .b64 %rd<17>;
ld.param.u64 %rd2, [blend_argb_param_0];
ld.param.u32 %r5, [blend_argb_param_1];
ld.param.u32 %r6, [blend_argb_param_2];
ld.param.u32 %r7, [blend_argb_param_3];
ld.param.u64 %rd3, [blend_argb_param_4];
ld.param.u32 %r8, [blend_argb_param_5];
ld.param.u32 %r11, [blend_argb_param_6];
ld.param.u32 %r9, [blend_argb_param_7];
ld.param.u32 %r10, [blend_argb_param_8];
mov.u32 %r12, %ntid.x;
mov.u32 %r13, %ctaid.x;
mov.u32 %r14, %tid.x;
mad.lo.s32 %r1, %r13, %r12, %r14;
mov.u32 %r15, %ntid.y;
mov.u32 %r16, %ctaid.y;
mov.u32 %r17, %tid.y;
mad.lo.s32 %r2, %r16, %r15, %r17;
setp.ge.s32 %p1, %r1, %r8;
setp.ge.s32 %p2, %r2, %r11;
or.pred %p3, %p1, %p2;
@%p3 bra $L__BB0_4;
add.s32 %r3, %r1, %r9;
add.s32 %r4, %r2, %r10;
or.b32 %r18, %r4, %r3;
setp.lt.s32 %p4, %r18, 0;
setp.ge.s32 %p5, %r3, %r6;
or.pred %p6, %p5, %p4;
setp.ge.s32 %p7, %r4, %r7;
or.pred %p8, %p7, %p6;
@%p8 bra $L__BB0_4;
mad.lo.s32 %r19, %r2, %r8, %r1;
mul.wide.s32 %rd4, %r19, 4;
cvta.to.global.u64 %rd5, %rd3;
add.s64 %rd1, %rd5, %rd4;
ld.global.u8 %rs1, [%rd1+3];
setp.eq.s16 %p9, %rs1, 0;
@%p9 bra $L__BB0_4;
cvt.u32.u16 %r20, %rs1;
mul.wide.s32 %rd6, %r4, %r5;
mul.wide.s32 %rd7, %r3, 4;
add.s64 %rd8, %rd6, %rd7;
cvta.to.global.u64 %rd9, %rd2;
add.s64 %rd10, %rd9, %rd8;
ld.global.u8 %r21, [%rd10];
ld.global.u8 %r22, [%rd1+2];
xor.b32 %r23, %r20, 255;
mul.lo.s32 %r24, %r23, %r21;
mad.lo.s32 %r25, %r22, %r20, %r24;
mul.wide.u32 %rd11, %r25, -2139062143;
shr.u64 %rd12, %rd11, 39;
st.global.u8 [%rd10], %rd12;
ld.global.u8 %r26, [%rd10+1];
ld.global.u8 %r27, [%rd1+1];
mul.lo.s32 %r28, %r23, %r26;
mad.lo.s32 %r29, %r27, %r20, %r28;
mul.wide.u32 %rd13, %r29, -2139062143;
shr.u64 %rd14, %rd13, 39;
st.global.u8 [%rd10+1], %rd14;
ld.global.u8 %r30, [%rd10+2];
ld.global.u8 %r31, [%rd1];
mul.lo.s32 %r32, %r23, %r30;
mad.lo.s32 %r33, %r31, %r20, %r32;
mul.wide.u32 %rd15, %r33, -2139062143;
shr.u64 %rd16, %rd15, 39;
st.global.u8 [%rd10+2], %rd16;
$L__BB0_4:
ret;
}
// .globl blend_yuv444
.visible .entry blend_yuv444(
.param .u64 blend_yuv444_param_0,
.param .u32 blend_yuv444_param_1,
.param .u32 blend_yuv444_param_2,
.param .u32 blend_yuv444_param_3,
.param .u64 blend_yuv444_param_4,
.param .u32 blend_yuv444_param_5,
.param .u32 blend_yuv444_param_6,
.param .u32 blend_yuv444_param_7,
.param .u32 blend_yuv444_param_8
)
{
.reg .pred %p<10>;
.reg .b16 %rs<5>;
.reg .f32 %f<16>;
.reg .b32 %r<49>;
.reg .b64 %rd<14>;
ld.param.u64 %rd2, [blend_yuv444_param_0];
ld.param.u32 %r5, [blend_yuv444_param_1];
ld.param.u32 %r6, [blend_yuv444_param_2];
ld.param.u32 %r7, [blend_yuv444_param_3];
ld.param.u64 %rd3, [blend_yuv444_param_4];
ld.param.u32 %r8, [blend_yuv444_param_5];
ld.param.u32 %r11, [blend_yuv444_param_6];
ld.param.u32 %r9, [blend_yuv444_param_7];
ld.param.u32 %r10, [blend_yuv444_param_8];
mov.u32 %r12, %ntid.x;
mov.u32 %r13, %ctaid.x;
mov.u32 %r14, %tid.x;
mad.lo.s32 %r1, %r13, %r12, %r14;
mov.u32 %r15, %ntid.y;
mov.u32 %r16, %ctaid.y;
mov.u32 %r17, %tid.y;
mad.lo.s32 %r2, %r16, %r15, %r17;
setp.ge.s32 %p1, %r1, %r8;
setp.ge.s32 %p2, %r2, %r11;
or.pred %p3, %p1, %p2;
@%p3 bra $L__BB1_4;
add.s32 %r3, %r1, %r9;
add.s32 %r4, %r2, %r10;
or.b32 %r18, %r4, %r3;
setp.lt.s32 %p4, %r18, 0;
setp.ge.s32 %p5, %r3, %r6;
or.pred %p6, %p5, %p4;
setp.ge.s32 %p7, %r4, %r7;
or.pred %p8, %p7, %p6;
@%p8 bra $L__BB1_4;
mad.lo.s32 %r19, %r2, %r8, %r1;
mul.wide.s32 %rd4, %r19, 4;
cvta.to.global.u64 %rd5, %rd3;
add.s64 %rd1, %rd5, %rd4;
ld.global.u8 %rs1, [%rd1+3];
setp.eq.s16 %p9, %rs1, 0;
@%p9 bra $L__BB1_4;
cvt.u32.u16 %r20, %rs1;
ld.global.u8 %rs2, [%rd1];
cvt.rn.f32.u16 %f1, %rs2;
ld.global.u8 %rs3, [%rd1+1];
cvt.rn.f32.u16 %f2, %rs3;
ld.global.u8 %rs4, [%rd1+2];
cvt.rn.f32.u16 %f3, %rs4;
fma.rn.f32 %f4, %f1, 0f3E3AFB7F, 0f41800000;
fma.rn.f32 %f5, %f2, 0f3F1D3C36, %f4;
fma.rn.f32 %f6, %f3, 0f3D7DF3B6, %f5;
add.f32 %f7, %f6, 0f3F000000;
cvt.rzi.s32.f32 %r21, %f7;
fma.rn.f32 %f8, %f1, 0fBDCE075F, 0f43000000;
fma.rn.f32 %f9, %f2, 0fBEAD5CFB, %f8;
fma.rn.f32 %f10, %f3, 0f3EE0DED3, %f9;
add.f32 %f11, %f10, 0f3F000000;
cvt.rzi.s32.f32 %r22, %f11;
fma.rn.f32 %f12, %f1, 0f3EE0DED3, 0f43000000;
fma.rn.f32 %f13, %f2, 0fBECC3C9F, %f12;
fma.rn.f32 %f14, %f3, 0fBD25119D, %f13;
add.f32 %f15, %f14, 0f3F000000;
cvt.rzi.s32.f32 %r23, %f15;
mul.wide.s32 %rd6, %r4, %r5;
cvt.s64.s32 %rd7, %r3;
add.s64 %rd8, %rd6, %rd7;
cvta.to.global.u64 %rd9, %rd2;
add.s64 %rd10, %rd9, %rd8;
ld.global.u8 %r24, [%rd10];
mul.lo.s32 %r25, %r21, %r20;
xor.b32 %r26, %r20, 255;
mad.lo.s32 %r27, %r26, %r24, %r25;
mul.hi.s32 %r28, %r27, -2139062143;
add.s32 %r29, %r28, %r27;
shr.u32 %r30, %r29, 31;
shr.u32 %r31, %r29, 7;
add.s32 %r32, %r31, %r30;
st.global.u8 [%rd10], %r32;
mul.wide.s32 %rd11, %r7, %r5;
add.s64 %rd12, %rd10, %rd11;
ld.global.u8 %r33, [%rd12];
mul.lo.s32 %r34, %r22, %r20;
mad.lo.s32 %r35, %r26, %r33, %r34;
mul.hi.s32 %r36, %r35, -2139062143;
add.s32 %r37, %r36, %r35;
shr.u32 %r38, %r37, 31;
shr.u32 %r39, %r37, 7;
add.s32 %r40, %r39, %r38;
st.global.u8 [%rd12], %r40;
add.s64 %rd13, %rd12, %rd11;
ld.global.u8 %r41, [%rd13];
mul.lo.s32 %r42, %r23, %r20;
mad.lo.s32 %r43, %r26, %r41, %r42;
mul.hi.s32 %r44, %r43, -2139062143;
add.s32 %r45, %r44, %r43;
shr.u32 %r46, %r45, 31;
shr.u32 %r47, %r45, 7;
add.s32 %r48, %r47, %r46;
st.global.u8 [%rd13], %r48;
$L__BB1_4:
ret;
}
// .globl blend_nv12
.visible .entry blend_nv12(
.param .u64 blend_nv12_param_0,
.param .u32 blend_nv12_param_1,
.param .u64 blend_nv12_param_2,
.param .u32 blend_nv12_param_3,
.param .u32 blend_nv12_param_4,
.param .u32 blend_nv12_param_5,
.param .u64 blend_nv12_param_6,
.param .u32 blend_nv12_param_7,
.param .u32 blend_nv12_param_8,
.param .u32 blend_nv12_param_9,
.param .u32 blend_nv12_param_10
)
{
.reg .pred %p<43>;
.reg .b16 %rs<17>;
.reg .f32 %f<108>;
.reg .b32 %r<123>;
.reg .b64 %rd<35>;
ld.param.u64 %rd11, [blend_nv12_param_0];
ld.param.u32 %r21, [blend_nv12_param_1];
ld.param.u64 %rd10, [blend_nv12_param_2];
ld.param.u32 %r22, [blend_nv12_param_3];
ld.param.u32 %r23, [blend_nv12_param_4];
ld.param.u32 %r24, [blend_nv12_param_5];
ld.param.u64 %rd12, [blend_nv12_param_6];
ld.param.u32 %r25, [blend_nv12_param_7];
ld.param.u32 %r26, [blend_nv12_param_8];
ld.param.u32 %r27, [blend_nv12_param_9];
ld.param.u32 %r28, [blend_nv12_param_10];
cvta.to.global.u64 %rd1, %rd11;
cvta.to.global.u64 %rd2, %rd12;
mov.u32 %r29, %ntid.x;
mov.u32 %r30, %ctaid.x;
mov.u32 %r31, %tid.x;
mad.lo.s32 %r32, %r30, %r29, %r31;
mov.u32 %r33, %ntid.y;
mov.u32 %r34, %ctaid.y;
mov.u32 %r35, %tid.y;
mad.lo.s32 %r36, %r34, %r33, %r35;
shl.b32 %r1, %r32, 1;
shl.b32 %r2, %r36, 1;
setp.ge.s32 %p1, %r1, %r25;
setp.ge.s32 %p2, %r2, %r26;
or.pred %p3, %p1, %p2;
mov.f32 %f102, 0f00000000;
mov.f32 %f103, 0f00000000;
mov.f32 %f104, 0f00000000;
@%p3 bra $L__BB2_19;
cvt.s64.s32 %rd3, %r21;
add.s32 %r3, %r2, %r28;
setp.ge.s32 %p4, %r3, %r24;
mul.lo.s32 %r4, %r2, %r25;
mul.wide.s32 %rd4, %r3, %r21;
add.s32 %r5, %r1, %r27;
or.b32 %r38, %r5, %r3;
setp.lt.s32 %p5, %r38, 0;
mov.u32 %r121, 0;
setp.ge.s32 %p6, %r5, %r23;
or.pred %p7, %p6, %p5;
or.pred %p8, %p4, %p7;
@%p8 bra $L__BB2_4;
add.s32 %r40, %r1, %r4;
mul.wide.s32 %rd13, %r40, 4;
add.s64 %rd5, %rd2, %rd13;
ld.global.u8 %rs1, [%rd5+3];
setp.eq.s16 %p9, %rs1, 0;
@%p9 bra $L__BB2_4;
cvt.u32.u16 %r42, %rs1;
ld.global.u8 %rs5, [%rd5];
cvt.rn.f32.u16 %f31, %rs5;
ld.global.u8 %rs6, [%rd5+1];
cvt.rn.f32.u16 %f32, %rs6;
ld.global.u8 %rs7, [%rd5+2];
cvt.rn.f32.u16 %f33, %rs7;
fma.rn.f32 %f34, %f31, 0f3E3AFB7F, 0f41800000;
fma.rn.f32 %f35, %f32, 0f3F1D3C36, %f34;
fma.rn.f32 %f36, %f33, 0f3D7DF3B6, %f35;
add.f32 %f37, %f36, 0f3F000000;
cvt.rzi.s32.f32 %r43, %f37;
cvt.s64.s32 %rd14, %r5;
add.s64 %rd15, %rd4, %rd14;
add.s64 %rd16, %rd1, %rd15;
ld.global.u8 %r44, [%rd16];
mul.lo.s32 %r45, %r43, %r42;
xor.b32 %r46, %r42, 255;
mad.lo.s32 %r47, %r46, %r44, %r45;
mul.hi.s32 %r48, %r47, -2139062143;
add.s32 %r49, %r48, %r47;
shr.u32 %r50, %r49, 31;
shr.u32 %r51, %r49, 7;
add.s32 %r52, %r51, %r50;
st.global.u8 [%rd16], %r52;
fma.rn.f32 %f38, %f31, 0fBDCE075F, 0f43000000;
fma.rn.f32 %f39, %f32, 0fBEAD5CFB, %f38;
fma.rn.f32 %f40, %f33, 0f3EE0DED3, %f39;
cvt.rn.f32.u16 %f104, %rs1;
fma.rn.f32 %f102, %f40, %f104, 0f00000000;
fma.rn.f32 %f41, %f31, 0f3EE0DED3, 0f43000000;
fma.rn.f32 %f42, %f32, 0fBECC3C9F, %f41;
fma.rn.f32 %f43, %f33, 0fBD25119D, %f42;
fma.rn.f32 %f103, %f43, %f104, 0f00000000;
mov.u32 %r121, 1;
$L__BB2_4:
add.s32 %r7, %r1, 1;
setp.ge.s32 %p10, %r7, %r25;
@%p10 bra $L__BB2_8;
add.s32 %r8, %r7, %r27;
or.b32 %r53, %r8, %r3;
setp.lt.s32 %p12, %r53, 0;
setp.ge.s32 %p13, %r8, %r23;
or.pred %p14, %p13, %p12;
or.pred %p15, %p4, %p14;
@%p15 bra $L__BB2_8;
add.s32 %r54, %r7, %r4;
mul.wide.s32 %rd17, %r54, 4;
add.s64 %rd6, %rd2, %rd17;
ld.global.u8 %rs2, [%rd6+3];
setp.eq.s16 %p16, %rs2, 0;
@%p16 bra $L__BB2_8;
cvt.u32.u16 %r55, %rs2;
ld.global.u8 %rs8, [%rd6];
cvt.rn.f32.u16 %f44, %rs8;
ld.global.u8 %rs9, [%rd6+1];
cvt.rn.f32.u16 %f45, %rs9;
ld.global.u8 %rs10, [%rd6+2];
cvt.rn.f32.u16 %f46, %rs10;
fma.rn.f32 %f47, %f44, 0f3E3AFB7F, 0f41800000;
fma.rn.f32 %f48, %f45, 0f3F1D3C36, %f47;
fma.rn.f32 %f49, %f46, 0f3D7DF3B6, %f48;
add.f32 %f50, %f49, 0f3F000000;
cvt.rzi.s32.f32 %r56, %f50;
cvt.s64.s32 %rd18, %r8;
add.s64 %rd19, %rd4, %rd18;
add.s64 %rd20, %rd1, %rd19;
ld.global.u8 %r57, [%rd20];
mul.lo.s32 %r58, %r56, %r55;
xor.b32 %r59, %r55, 255;
mad.lo.s32 %r60, %r59, %r57, %r58;
mul.hi.s32 %r61, %r60, -2139062143;
add.s32 %r62, %r61, %r60;
shr.u32 %r63, %r62, 31;
shr.u32 %r64, %r62, 7;
add.s32 %r65, %r64, %r63;
st.global.u8 [%rd20], %r65;
fma.rn.f32 %f51, %f44, 0fBDCE075F, 0f43000000;
fma.rn.f32 %f52, %f45, 0fBEAD5CFB, %f51;
fma.rn.f32 %f53, %f46, 0f3EE0DED3, %f52;
cvt.rn.f32.u16 %f54, %rs2;
fma.rn.f32 %f102, %f53, %f54, %f102;
fma.rn.f32 %f55, %f44, 0f3EE0DED3, 0f43000000;
fma.rn.f32 %f56, %f45, 0fBECC3C9F, %f55;
fma.rn.f32 %f57, %f46, 0fBD25119D, %f56;
fma.rn.f32 %f103, %f57, %f54, %f103;
add.f32 %f104, %f104, %f54;
add.s32 %r121, %r121, 1;
$L__BB2_8:
add.s32 %r11, %r2, 1;
setp.ge.s32 %p17, %r11, %r26;
add.s32 %r12, %r11, %r28;
add.s32 %r13, %r4, %r25;
cvt.s64.s32 %rd21, %r12;
mul.lo.s64 %rd7, %rd21, %rd3;
@%p17 bra $L__BB2_12;
setp.ge.s32 %p18, %r12, %r24;
or.b32 %r66, %r5, %r12;
setp.lt.s32 %p19, %r66, 0;
or.pred %p21, %p6, %p19;
or.pred %p22, %p18, %p21;
@%p22 bra $L__BB2_12;
add.s32 %r67, %r1, %r13;
mul.wide.s32 %rd22, %r67, 4;
add.s64 %rd8, %rd2, %rd22;
ld.global.u8 %rs3, [%rd8+3];
setp.eq.s16 %p23, %rs3, 0;
@%p23 bra $L__BB2_12;
cvt.u32.u16 %r68, %rs3;
ld.global.u8 %rs11, [%rd8];
cvt.rn.f32.u16 %f58, %rs11;
ld.global.u8 %rs12, [%rd8+1];
cvt.rn.f32.u16 %f59, %rs12;
ld.global.u8 %rs13, [%rd8+2];
cvt.rn.f32.u16 %f60, %rs13;
fma.rn.f32 %f61, %f58, 0f3E3AFB7F, 0f41800000;
fma.rn.f32 %f62, %f59, 0f3F1D3C36, %f61;
fma.rn.f32 %f63, %f60, 0f3D7DF3B6, %f62;
add.f32 %f64, %f63, 0f3F000000;
cvt.rzi.s32.f32 %r69, %f64;
cvt.s64.s32 %rd23, %r5;
add.s64 %rd24, %rd7, %rd23;
add.s64 %rd25, %rd1, %rd24;
ld.global.u8 %r70, [%rd25];
mul.lo.s32 %r71, %r69, %r68;
xor.b32 %r72, %r68, 255;
mad.lo.s32 %r73, %r72, %r70, %r71;
mul.hi.s32 %r74, %r73, -2139062143;
add.s32 %r75, %r74, %r73;
shr.u32 %r76, %r75, 31;
shr.u32 %r77, %r75, 7;
add.s32 %r78, %r77, %r76;
st.global.u8 [%rd25], %r78;
fma.rn.f32 %f65, %f58, 0fBDCE075F, 0f43000000;
fma.rn.f32 %f66, %f59, 0fBEAD5CFB, %f65;
fma.rn.f32 %f67, %f60, 0f3EE0DED3, %f66;
cvt.rn.f32.u16 %f68, %rs3;
fma.rn.f32 %f102, %f67, %f68, %f102;
fma.rn.f32 %f69, %f58, 0f3EE0DED3, 0f43000000;
fma.rn.f32 %f70, %f59, 0fBECC3C9F, %f69;
fma.rn.f32 %f71, %f60, 0fBD25119D, %f70;
fma.rn.f32 %f103, %f71, %f68, %f103;
add.f32 %f104, %f104, %f68;
add.s32 %r121, %r121, 1;
$L__BB2_12:
or.pred %p26, %p17, %p10;
@%p26 bra $L__BB2_16;
setp.ge.s32 %p27, %r12, %r24;
add.s32 %r16, %r7, %r27;
or.b32 %r79, %r16, %r12;
setp.lt.s32 %p28, %r79, 0;
setp.ge.s32 %p29, %r16, %r23;
or.pred %p30, %p29, %p28;
or.pred %p31, %p27, %p30;
@%p31 bra $L__BB2_16;
add.s32 %r80, %r7, %r13;
mul.wide.s32 %rd26, %r80, 4;
add.s64 %rd9, %rd2, %rd26;
ld.global.u8 %rs4, [%rd9+3];
setp.eq.s16 %p32, %rs4, 0;
@%p32 bra $L__BB2_16;
cvt.u32.u16 %r81, %rs4;
ld.global.u8 %rs14, [%rd9];
cvt.rn.f32.u16 %f72, %rs14;
ld.global.u8 %rs15, [%rd9+1];
cvt.rn.f32.u16 %f73, %rs15;
ld.global.u8 %rs16, [%rd9+2];
cvt.rn.f32.u16 %f74, %rs16;
fma.rn.f32 %f75, %f72, 0f3E3AFB7F, 0f41800000;
fma.rn.f32 %f76, %f73, 0f3F1D3C36, %f75;
fma.rn.f32 %f77, %f74, 0f3D7DF3B6, %f76;
add.f32 %f78, %f77, 0f3F000000;
cvt.rzi.s32.f32 %r82, %f78;
cvt.s64.s32 %rd27, %r16;
add.s64 %rd28, %rd7, %rd27;
add.s64 %rd29, %rd1, %rd28;
ld.global.u8 %r83, [%rd29];
mul.lo.s32 %r84, %r82, %r81;
xor.b32 %r85, %r81, 255;
mad.lo.s32 %r86, %r85, %r83, %r84;
mul.hi.s32 %r87, %r86, -2139062143;
add.s32 %r88, %r87, %r86;
shr.u32 %r89, %r88, 31;
shr.u32 %r90, %r88, 7;
add.s32 %r91, %r90, %r89;
st.global.u8 [%rd29], %r91;
fma.rn.f32 %f79, %f72, 0fBDCE075F, 0f43000000;
fma.rn.f32 %f80, %f73, 0fBEAD5CFB, %f79;
fma.rn.f32 %f81, %f74, 0f3EE0DED3, %f80;
cvt.rn.f32.u16 %f82, %rs4;
fma.rn.f32 %f102, %f81, %f82, %f102;
fma.rn.f32 %f83, %f72, 0f3EE0DED3, 0f43000000;
fma.rn.f32 %f84, %f73, 0fBECC3C9F, %f83;
fma.rn.f32 %f85, %f74, 0fBD25119D, %f84;
fma.rn.f32 %f103, %f85, %f82, %f103;
add.f32 %f104, %f104, %f82;
add.s32 %r121, %r121, 1;
$L__BB2_16:
setp.eq.s32 %p33, %r121, 0;
setp.le.f32 %p34, %f104, 0f00000000;
or.pred %p35, %p34, %p33;
@%p35 bra $L__BB2_19;
shr.u32 %r92, %r5, 31;
add.s32 %r93, %r5, %r92;
shr.s32 %r19, %r93, 1;
setp.lt.s32 %p36, %r3, -1;
setp.lt.s32 %p37, %r5, -1;
or.pred %p38, %p37, %p36;
and.b32 %r94, %r93, -2;
setp.ge.s32 %p39, %r94, %r23;
or.pred %p40, %p38, %p39;
shr.u32 %r95, %r3, 31;
add.s32 %r96, %r3, %r95;
shr.s32 %r20, %r96, 1;
and.b32 %r97, %r96, -2;
setp.ge.s32 %p41, %r97, %r24;
or.pred %p42, %p40, %p41;
@%p42 bra $L__BB2_19;
div.rn.f32 %f86, %f102, %f104;
add.f32 %f87, %f86, 0f3F000000;
cvt.rzi.s32.f32 %r98, %f87;
div.rn.f32 %f88, %f103, %f104;
add.f32 %f89, %f88, 0f3F000000;
cvt.rzi.s32.f32 %r99, %f89;
cvt.rn.f32.s32 %f90, %r121;
div.rn.f32 %f91, %f104, %f90;
add.f32 %f92, %f91, 0f3F000000;
cvt.rzi.s32.f32 %r100, %f92;
mul.wide.s32 %rd30, %r20, %r22;
mul.wide.s32 %rd31, %r19, 2;
add.s64 %rd32, %rd30, %rd31;
cvta.to.global.u64 %rd33, %rd10;
add.s64 %rd34, %rd33, %rd32;
ld.global.u8 %r101, [%rd34];
mul.lo.s32 %r102, %r100, %r98;
mov.u32 %r103, 255;
sub.s32 %r104, %r103, %r100;
mad.lo.s32 %r105, %r104, %r101, %r102;
mul.hi.s32 %r106, %r105, -2139062143;
add.s32 %r107, %r106, %r105;
shr.u32 %r108, %r107, 31;
shr.u32 %r109, %r107, 7;
add.s32 %r110, %r109, %r108;
st.global.u8 [%rd34], %r110;
ld.global.u8 %r111, [%rd34+1];
mul.lo.s32 %r112, %r100, %r99;
mad.lo.s32 %r113, %r104, %r111, %r112;
mul.hi.s32 %r114, %r113, -2139062143;
add.s32 %r115, %r114, %r113;
shr.u32 %r116, %r115, 31;
shr.u32 %r117, %r115, 7;
add.s32 %r118, %r117, %r116;
st.global.u8 [%rd34+1], %r118;
$L__BB2_19:
ret;
}
@@ -41,6 +41,12 @@ use std::ptr;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
/// Prebuilt PTX for the cursor-overlay blend kernels (cursor-as-metadata). Source is
/// `cursor_blend.cu` beside this file; regenerate with
/// `nvcc -ptx -arch=compute_75 cursor_blend.cu -o cursor_blend.ptx` after editing. JIT'd by the
/// driver, so it runs on any Turing-or-newer GPU.
const CURSOR_PTX: &[u8] = include_bytes!("cursor_blend.ptx");
// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table (Linux). Same shape as the Windows backend's `EncodeApi`, minus
// the async-event entry points (Windows-only). Resolved once from `libnvidia-encode.so.1` — the two
@@ -305,6 +311,12 @@ pub struct NvencCudaEncoder {
split_mode: u32,
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for one loss.
last_rfi_range: Option<(i64, i64)>,
/// Cursor-as-metadata GPU blend (loaded lazily on the first frame that carries a cursor, once the
/// CUDA context is current). `None` until then or if the module load fails; `cursor_tried` stops
/// re-attempting a failed load every frame. `cursor_serial` tracks the uploaded bitmap.
cursor: Option<cuda::CursorBlend>,
cursor_tried: bool,
cursor_serial: u64,
}
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
@@ -367,6 +379,9 @@ impl NvencCudaEncoder {
frame_idx: 0,
force_kf: false,
pending_anchor: false,
cursor: None,
cursor_tried: false,
cursor_serial: u64::MAX,
inited: false,
rfi_supported: false,
custom_vbv: false,
@@ -937,6 +952,50 @@ impl Encoder for NvencCudaEncoder {
// Copy the captured buffer into this slot's input surface before encoding it.
self.copy_into_slot(buf, slot)?;
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface (a tiny kernel
// over the cursor's rect — never the compositor's dmabuf). The PTX module loads lazily on the
// first cursor frame now that the CUDA context is current; any failure degrades to no cursor,
// never a dropped frame.
if let Some(ov) = &captured.cursor {
if !self.cursor_tried {
self.cursor_tried = true;
match cuda::CursorBlend::new(CURSOR_PTX) {
Ok(cb) => self.cursor = Some(cb),
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
"NVENC (Linux): cursor blend module load failed — cursor not composited"
),
}
}
if let Some(cb) = &self.cursor {
if self.cursor_serial != ov.serial {
match cb.upload(ov.rgba.as_slice(), ov.w, ov.h) {
Ok(()) => self.cursor_serial = ov.serial,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "cursor upload failed")
}
}
}
let s = &self.ring[slot].surface;
// surfW = content width; surfH = the surface's allocated height (matches
// `copy_into_slot`'s plane math). Cursor pixels past the content are in cropped
// padding rows — harmless.
let (w, h) = (self.width, s.height);
let r = match self.buffer_fmt {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
cb.blend_yuv444(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
}
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
cb.blend_nv12(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y)
}
_ => cb.blend_argb(s.ptr, s.pitch, w, h, ov.w, ov.h, ov.x, ov.y),
};
if let Err(e) = r {
tracing::warn!(error = %format!("{e:#}"), "cursor blend launch failed");
}
}
}
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
@@ -1236,6 +1295,7 @@ mod tests {
pts_ns: i as u64 * 16_666_667,
format: PixelFormat::Nv12,
payload: FramePayload::Cuda(buf),
cursor: None,
}
}
@@ -1350,6 +1410,7 @@ mod tests {
pts_ns: i as u64 * 16_666_667,
format: PixelFormat::Yuv444,
payload: FramePayload::Cuda(buf),
cursor: None,
};
enc.submit_indexed(&frame, i).expect("submit 444");
while let Some(_au) = enc.poll().expect("poll") {
@@ -38,6 +38,9 @@ use std::os::raw::c_char;
/// uses. PyroWave carries no VUI, so the colour contract is fixed by this shader: the Phase-2
/// client CSC must assume BT.709 limited range.
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
/// Fixed cursor-overlay texture size (px) — mirrors `vulkan_video.rs`; the shared CSC shader bounds
/// sampling by its push constant, so one allocation fits every pointer bitmap.
const CURSOR_MAX: u32 = 256;
/// Max resident dmabuf imports (mirrors `vulkan_video.rs` — PipeWire cycles a small fixed pool).
const IMPORT_CACHE_CAP: usize = 16;
/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta;
@@ -169,6 +172,17 @@ pub struct PyroWaveEncoder {
uv_mem: vk::DeviceMemory,
uv_view: vk::ImageView,
// Cursor overlay (cursor-as-metadata): a fixed CURSOR_MAX² RGBA8 sampled image (bound at binding
// 3) + host staging, re-uploaded only when the bitmap changes (`cursor_serial`). Single (not
// ring) because PyroWave encodes one frame synchronously — no in-flight overlap to race.
cursor_img: vk::Image,
cursor_mem: vk::DeviceMemory,
cursor_view: vk::ImageView,
cursor_stage: vk::Buffer,
cursor_stage_mem: vk::DeviceMemory,
cursor_serial: u64,
cursor_ready: bool,
// Per-buffer dmabuf-import cache keyed by (st_dev, st_ino) — mirrors `vulkan_video.rs`.
import_cache: Vec<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>,
// CPU-input staging (software capture / smoke tests), lazily (re)created on format change.
@@ -420,14 +434,22 @@ impl PyroWaveEncoder {
sb(0, vk::DescriptorType::COMBINED_IMAGE_SAMPLER),
sb(1, vk::DescriptorType::STORAGE_IMAGE),
sb(2, vk::DescriptorType::STORAGE_IMAGE),
sb(3, vk::DescriptorType::COMBINED_IMAGE_SAMPLER), // cursor overlay
];
let csc_dsl = device.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)?;
let dsls = [csc_dsl];
// Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (matches the shared CSC shader).
let pc_ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::COMPUTE)
.offset(0)
.size(16)];
let csc_layout = device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default().set_layouts(&dsls),
&vk::PipelineLayoutCreateInfo::default()
.set_layouts(&dsls)
.push_constant_ranges(&pc_ranges),
None,
)?;
let stage = vk::PipelineShaderStageCreateInfo::default()
@@ -448,7 +470,8 @@ impl PyroWaveEncoder {
let pool_sizes = [
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1),
// binding 0 (RGB) + binding 3 (cursor).
.descriptor_count(2),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_IMAGE)
.descriptor_count(2),
@@ -464,13 +487,44 @@ impl PyroWaveEncoder {
.descriptor_pool(csc_pool)
.set_layouts(&dsls),
)?[0];
// Bindings 1/2 (Y, UV storage targets) are fixed for the encoder's lifetime.
// Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (bound at binding 3).
let (cursor_img, cursor_mem, cursor_view) = make_plain_image(
&device,
&mem_props,
vk::Format::R8G8B8A8_UNORM,
CURSOR_MAX,
CURSOR_MAX,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?;
let cursor_stage = device.create_buffer(
&vk::BufferCreateInfo::default()
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
None,
)?;
let cs_req = device.get_buffer_memory_requirements(cursor_stage);
let cursor_stage_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(cs_req.size)
.memory_type_index(find_mem(
&mem_props,
cs_req.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)),
None,
)?;
device.bind_buffer_memory(cursor_stage, cursor_stage_mem, 0)?;
// Bindings 1/2 (Y, UV storage targets) + 3 (cursor sampler) are fixed for the encoder's life.
let yi = [vk::DescriptorImageInfo::default()
.image_view(y_view)
.image_layout(vk::ImageLayout::GENERAL)];
let uvi = [vk::DescriptorImageInfo::default()
.image_view(uv_view)
.image_layout(vk::ImageLayout::GENERAL)];
let curi = [vk::DescriptorImageInfo::default()
.sampler(sampler)
.image_view(cursor_view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
device.update_descriptor_sets(
&[
vk::WriteDescriptorSet::default()
@@ -483,6 +537,11 @@ impl PyroWaveEncoder {
.dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&uvi),
vk::WriteDescriptorSet::default()
.dst_set(csc_set)
.dst_binding(3)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&curi),
],
&[],
);
@@ -533,6 +592,13 @@ impl PyroWaveEncoder {
uv_img,
uv_mem,
uv_view,
cursor_img,
cursor_mem,
cursor_view,
cursor_stage,
cursor_stage_mem,
cursor_serial: u64::MAX,
cursor_ready: false,
import_cache: Vec::new(),
cpu_img: None,
cpu_stage: None,
@@ -566,6 +632,119 @@ impl PyroWaveEncoder {
);
}
/// Cursor-as-metadata: bring the cursor image up to date for this frame and return the shader
/// push constant `[origin_x, origin_y, size_w, size_h]` (size 0 ⇒ the CSC skips the blend).
/// Records the small upload (only when the bitmap `serial` changed) + layout transition into
/// `cmd`, ahead of the CSC dispatch that samples binding 3. Encode is synchronous, so the single
/// shared image never races a prior frame; the first use transitions it to SHADER_READ_ONLY.
unsafe fn prep_cursor(
&mut self,
cursor: Option<&crate::capture::CursorOverlay>,
) -> Result<[i32; 4]> {
let dev = self.device.clone();
let cmd = self.cmd;
let img = self.cursor_img;
let ready = self.cursor_ready;
let barrier = |old: vk::ImageLayout, new: vk::ImageLayout, ss, sa, ds, da| {
vk::ImageMemoryBarrier2::default()
.src_stage_mask(ss)
.src_access_mask(sa)
.dst_stage_mask(ds)
.dst_access_mask(da)
.old_layout(old)
.new_layout(new)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(img)
.subresource_range(color_range(0))
};
match cursor {
Some(c) if !c.rgba.is_empty() => {
let cw = c.w.min(CURSOR_MAX);
let ch = c.h.min(CURSOR_MAX);
if self.cursor_serial != c.serial {
let bytes = (cw as usize) * (ch as usize) * 4;
let ptr = dev.map_memory(
self.cursor_stage_mem,
0,
bytes as u64,
vk::MemoryMapFlags::empty(),
)?;
std::ptr::copy_nonoverlapping(
c.rgba.as_ptr(),
ptr as *mut u8,
bytes.min(c.rgba.len()),
);
dev.unmap_memory(self.cursor_stage_mem);
let old = if ready {
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL
} else {
vk::ImageLayout::UNDEFINED
};
dev.cmd_pipeline_barrier2(
cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
old,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::PipelineStageFlags2::NONE,
vk::AccessFlags2::NONE,
vk::PipelineStageFlags2::ALL_TRANSFER,
vk::AccessFlags2::TRANSFER_WRITE,
)]),
);
dev.cmd_copy_buffer_to_image(
cmd,
self.cursor_stage,
img,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[vk::BufferImageCopy::default()
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})],
);
dev.cmd_pipeline_barrier2(
cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
vk::PipelineStageFlags2::ALL_TRANSFER,
vk::AccessFlags2::TRANSFER_WRITE,
vk::PipelineStageFlags2::COMPUTE_SHADER,
vk::AccessFlags2::SHADER_READ,
)]),
);
self.cursor_serial = c.serial;
self.cursor_ready = true;
}
Ok([c.x, c.y, cw as i32, ch as i32])
}
_ => {
if !ready {
dev.cmd_pipeline_barrier2(
cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
vk::PipelineStageFlags2::NONE,
vk::AccessFlags2::NONE,
vk::PipelineStageFlags2::COMPUTE_SHADER,
vk::AccessFlags2::SHADER_READ,
)]),
);
self.cursor_ready = true;
}
Ok([0, 0, 0, 0])
}
}
}
/// Import a dmabuf with per-buffer caching — same policy as `vulkan_video.rs::import_cached`.
unsafe fn import_cached(
&mut self,
@@ -664,6 +843,10 @@ impl PyroWaveEncoder {
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)?;
// Cursor-as-metadata: refresh the cursor image (only when the bitmap changed) + get the
// shader push constant. Recorded into `self.cmd` before the CSC dispatch samples binding 3.
let cursor_pc = self.prep_cursor(frame.cursor.as_ref())?;
// ---- ingest RGB (same barrier discipline as vulkan_video.rs) ----
let rgb_view = match &frame.payload {
FramePayload::Dmabuf(d) => {
@@ -780,6 +963,17 @@ impl PyroWaveEncoder {
&[self.csc_set],
&[],
);
let mut pc_bytes = [0u8; 16];
for (i, v) in cursor_pc.iter().enumerate() {
pc_bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_ne_bytes());
}
dev.cmd_push_constants(
self.cmd,
self.csc_layout,
vk::ShaderStageFlags::COMPUTE,
0,
&pc_bytes,
);
dev.cmd_dispatch(self.cmd, (w / 2).div_ceil(8), (h / 2).div_ceil(8), 1);
// CSC storage writes -> pyrowave's sampled reads (images stay GENERAL — the layout
@@ -1095,6 +1289,11 @@ impl Drop for PyroWaveEncoder {
self.device.destroy_image_view(self.uv_view, None);
self.device.destroy_image(self.uv_img, None);
self.device.free_memory(self.uv_mem, None);
self.device.destroy_image_view(self.cursor_view, None);
self.device.destroy_image(self.cursor_img, None);
self.device.free_memory(self.cursor_mem, None);
self.device.destroy_buffer(self.cursor_stage, None);
self.device.free_memory(self.cursor_stage_mem, None);
self.device.destroy_device(None);
self.instance.destroy_instance(None);
}
@@ -1117,6 +1316,7 @@ mod tests {
pts_ns,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(buf),
cursor: None,
}
}
@@ -1334,6 +1534,7 @@ mod tests {
pts_ns: seed as u64 * 16_666_667,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(buf),
cursor: None,
}
}
@@ -1,12 +1,31 @@
#version 450
// RGB(A) -> NV12 (BT.709 limited range). One invocation per chroma sample = 2x2 luma block.
// Optionally blends a straight-alpha RGBA cursor bitmap over the RGB *before* the YUV conversion
// (so the chroma stays correct) — cursor-as-metadata for the GPU zero-copy paths. The blend is
// gated by the push constant and touches only the cursor's small rectangle, so a disabled or
// off-screen cursor costs one compare per invocation.
layout(local_size_x = 8, local_size_y = 8) in;
layout(binding = 0) uniform sampler2D rgb; // packed RGB input (sampled; BGRA import ok)
layout(binding = 1, r8) uniform writeonly image2D yImg; // full-res Y
layout(binding = 2, rg8) uniform writeonly image2D uvImg; // half-res UV (interleaved)
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
layout(push_constant) uniform Push {
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
} pc;
float lumaY(vec3 c) { return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; }
// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle.
vec3 withCursor(ivec2 p, vec3 col) {
if (pc.curSize.x <= 0) return col;
ivec2 cp = p - pc.curOrigin;
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
vec4 c = texelFetch(cursorTex, cp, 0);
return mix(col, c.rgb, c.a);
}
// Source may be SMALLER than the coded (16-aligned) Y plane — e.g. 1080 source vs 1088 coded. Clamp
// every fetch to the source edge so the alignment-padding rows duplicate the last real row instead
// of reading out of bounds (undefined → green garbage that shows if a client ignores the SPS
@@ -17,10 +36,10 @@ void main() {
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
ivec2 p = uvc * 2;
if (p.x >= sz.x || p.y >= sz.y) return;
vec3 c00 = texelFetch(rgb, min(p, rmax), 0).rgb;
vec3 c10 = texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb;
vec3 c01 = texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb;
vec3 c11 = texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb;
vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb);
vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb);
vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb);
imageStore(yImg, p, vec4(lumaY(c00), 0, 0, 1));
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10), 0, 0, 1));
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01), 0, 0, 1));
Binary file not shown.
@@ -26,6 +26,10 @@ const IMPORT_CACHE_CAP: usize = 16;
// Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file;
// regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader.
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
/// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads
/// into the top-left and the shader's push constant bounds sampling, so one allocation fits every
/// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant.
const CURSOR_MAX: u32 = 256;
/// DPB ring depth (well under the RADV `maxDpbSlots=17`); also the RFI recovery window.
const DPB_SLOTS: u32 = 8;
/// In-flight frame ring: how many captures may have GPU work outstanding at once. 2 overlaps a
@@ -131,6 +135,18 @@ struct Frame {
// CPU-input staging (lazily sized; only the software-capture / smoke-test path uses it).
cpu_img: Option<(vk::Image, vk::DeviceMemory, vk::ImageView, vk::Format)>,
cpu_stage: Option<(vk::Buffer, vk::DeviceMemory, u64)>,
// Per-slot cursor overlay (cursor-as-metadata): a fixed CURSOR_MAX² RGBA8 sampled image (bound
// once at binding 3) + host staging. Re-uploaded only when the bitmap changes (`cursor_serial`);
// `cursor_ready` records the one-time UNDEFINED→SHADER_READ_ONLY transition so binding 3 is a
// valid layout even with no cursor. Per-slot (not shared) so a shape change never races a prior
// frame's in-flight CSC read.
cursor_img: vk::Image,
cursor_mem: vk::DeviceMemory,
cursor_view: vk::ImageView,
cursor_stage: vk::Buffer,
cursor_stage_mem: vk::DeviceMemory,
cursor_serial: u64,
cursor_ready: bool,
// Frame metadata, set at submit and read back at poll (valid only while this slot is in flight).
pts_ns: u64,
keyframe: bool,
@@ -546,14 +562,22 @@ impl VulkanVideoEncoder {
sb(0, vk::DescriptorType::COMBINED_IMAGE_SAMPLER),
sb(1, vk::DescriptorType::STORAGE_IMAGE),
sb(2, vk::DescriptorType::STORAGE_IMAGE),
sb(3, vk::DescriptorType::COMBINED_IMAGE_SAMPLER), // cursor overlay
];
let csc_dsl = device.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)?;
let dsls = [csc_dsl];
// Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (size.x<=0 disables the blend).
let pc_ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::COMPUTE)
.offset(0)
.size(16)];
let csc_layout = device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default().set_layouts(&dsls),
&vk::PipelineLayoutCreateInfo::default()
.set_layouts(&dsls)
.push_constant_ranges(&pc_ranges),
None,
)?;
let stage = vk::PipelineShaderStageCreateInfo::default()
@@ -575,7 +599,8 @@ impl VulkanVideoEncoder {
let pool_sizes = [
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(nframes as u32),
// binding 0 (RGB) + binding 3 (cursor) per set.
.descriptor_count(2 * nframes as u32),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_IMAGE)
.descriptor_count(2 * nframes as u32),
@@ -621,6 +646,7 @@ impl VulkanVideoEncoder {
cmd_pool,
compute_pool,
bs_size,
sampler,
)?);
}
@@ -694,6 +720,120 @@ impl VulkanVideoEncoder {
);
}
/// Cursor-as-metadata: bring slot `slot`'s cursor image up to date for this frame and return the
/// shader push constant `[origin_x, origin_y, size_w, size_h]` (size 0 ⇒ the CSC skips the blend).
/// Records the small upload (only when the bitmap `serial` changed) + layout transition into
/// `compute_cmd`, ahead of the CSC dispatch that samples binding 3. Per-slot, so no cross-frame
/// race; the first use of a slot always transitions the image to a valid SHADER_READ_ONLY layout.
unsafe fn prep_cursor(
&mut self,
slot: usize,
compute_cmd: vk::CommandBuffer,
cursor: Option<&crate::capture::CursorOverlay>,
) -> Result<[i32; 4]> {
let dev = self.device.clone();
let img = self.frames[slot].cursor_img;
let ready = self.frames[slot].cursor_ready;
let barrier = |old: vk::ImageLayout, new: vk::ImageLayout, ss, sa, ds, da| {
vk::ImageMemoryBarrier2::default()
.src_stage_mask(ss)
.src_access_mask(sa)
.dst_stage_mask(ds)
.dst_access_mask(da)
.old_layout(old)
.new_layout(new)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(img)
.subresource_range(color_range(0))
};
match cursor {
Some(c) if !c.rgba.is_empty() => {
let cw = c.w.min(CURSOR_MAX);
let ch = c.h.min(CURSOR_MAX);
if self.frames[slot].cursor_serial != c.serial {
let stage = self.frames[slot].cursor_stage;
let stage_mem = self.frames[slot].cursor_stage_mem;
let bytes = (cw as usize) * (ch as usize) * 4;
let ptr =
dev.map_memory(stage_mem, 0, bytes as u64, vk::MemoryMapFlags::empty())?;
std::ptr::copy_nonoverlapping(
c.rgba.as_ptr(),
ptr as *mut u8,
bytes.min(c.rgba.len()),
);
dev.unmap_memory(stage_mem);
let old = if ready {
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL
} else {
vk::ImageLayout::UNDEFINED
};
dev.cmd_pipeline_barrier2(
compute_cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
old,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::PipelineStageFlags2::NONE,
vk::AccessFlags2::NONE,
vk::PipelineStageFlags2::ALL_TRANSFER,
vk::AccessFlags2::TRANSFER_WRITE,
)]),
);
dev.cmd_copy_buffer_to_image(
compute_cmd,
stage,
img,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[vk::BufferImageCopy::default()
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})],
);
dev.cmd_pipeline_barrier2(
compute_cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
vk::PipelineStageFlags2::ALL_TRANSFER,
vk::AccessFlags2::TRANSFER_WRITE,
vk::PipelineStageFlags2::COMPUTE_SHADER,
vk::AccessFlags2::SHADER_READ,
)]),
);
self.frames[slot].cursor_serial = c.serial;
self.frames[slot].cursor_ready = true;
}
Ok([c.x, c.y, cw as i32, ch as i32])
}
_ => {
if !ready {
// No cursor uploaded yet — transition UNDEFINED→READ_ONLY once so binding 3 is a
// valid layout for the (guarded, never-sampled) shader read.
dev.cmd_pipeline_barrier2(
compute_cmd,
&vk::DependencyInfo::default().image_memory_barriers(&[barrier(
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
vk::PipelineStageFlags2::NONE,
vk::AccessFlags2::NONE,
vk::PipelineStageFlags2::COMPUTE_SHADER,
vk::AccessFlags2::SHADER_READ,
)]),
);
self.frames[slot].cursor_ready = true;
}
Ok([0, 0, 0, 0])
}
}
}
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys.
unsafe fn import_dmabuf(
&self,
@@ -881,6 +1021,10 @@ impl VulkanVideoEncoder {
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)?;
// Cursor-as-metadata: refresh this slot's cursor image (only when the bitmap changed) and
// get the shader push constant. Recorded into `compute_cmd` before the CSC dispatch samples it.
let cursor_pc = self.prep_cursor(slot, compute_cmd, frame.cursor.as_ref())?;
let rgb_view = match &frame.payload {
FramePayload::Dmabuf(d) => {
// Reuse the per-buffer import (PipeWire cycles a small pool) — no per-frame VkImage
@@ -1025,6 +1169,17 @@ impl VulkanVideoEncoder {
&[csc_set],
&[],
);
let mut pc_bytes = [0u8; 16];
for (i, v) in cursor_pc.iter().enumerate() {
pc_bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_ne_bytes());
}
dev.cmd_push_constants(
compute_cmd,
self.csc_layout,
vk::ShaderStageFlags::COMPUTE,
0,
&pc_bytes,
);
dev.cmd_dispatch(compute_cmd, (w / 2).div_ceil(8), (h_px / 2).div_ceil(8), 1);
// y/uv shader-write -> transfer-read (stay GENERAL); then copy into nv12 planes
@@ -1891,6 +2046,11 @@ impl Drop for VulkanVideoEncoder {
self.device.destroy_buffer(b, None);
self.device.free_memory(m, None);
}
self.device.destroy_image_view(f.cursor_view, None);
self.device.destroy_image(f.cursor_img, None);
self.device.free_memory(f.cursor_mem, None);
self.device.destroy_buffer(f.cursor_stage, None);
self.device.free_memory(f.cursor_stage_mem, None);
}
self.device.destroy_command_pool(self.compute_pool, None);
self.device.destroy_command_pool(self.cmd_pool, None);
@@ -1995,6 +2155,7 @@ unsafe fn make_frame(
cmd_pool: vk::CommandPool,
compute_pool: vk::CommandPool,
bs_size: u64,
sampler: vk::Sampler,
) -> Result<Frame> {
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
let (nv12_src, nv12_mem) = make_video_image(
@@ -2026,7 +2187,37 @@ unsafe fn make_frame(
h / 2,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
)?;
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use.
// Cursor overlay: fixed CURSOR_MAX² RGBA8 sampled image + host staging (cursor-as-metadata). The
// view/descriptor is static (bound at binding 3 below); only the image *content* changes, and
// only when the pointer bitmap does — see `prep_cursor`.
let (cursor_img, cursor_mem, cursor_view) = make_plain_image(
device,
mem_props,
vk::Format::R8G8B8A8_UNORM,
CURSOR_MAX,
CURSOR_MAX,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?;
let cursor_stage = device.create_buffer(
&vk::BufferCreateInfo::default()
.size((CURSOR_MAX * CURSOR_MAX * 4) as u64)
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
None,
)?;
let cs_req = device.get_buffer_memory_requirements(cursor_stage);
let cursor_stage_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(cs_req.size)
.memory_type_index(find_mem(
mem_props,
cs_req.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)),
None,
)?;
device.bind_buffer_memory(cursor_stage, cursor_stage_mem, 0)?;
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use; binding 3
// (cursor) points at the static cursor image (its layout is SHADER_READ_ONLY once prepped).
let dsls = [csc_dsl];
let csc_set = device.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
@@ -2039,6 +2230,10 @@ unsafe fn make_frame(
let uv_info = [vk::DescriptorImageInfo::default()
.image_view(uv_view)
.image_layout(vk::ImageLayout::GENERAL)];
let cur_info = [vk::DescriptorImageInfo::default()
.sampler(sampler)
.image_view(cursor_view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
device.update_descriptor_sets(
&[
vk::WriteDescriptorSet::default()
@@ -2051,6 +2246,11 @@ unsafe fn make_frame(
.dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&uv_info),
vk::WriteDescriptorSet::default()
.dst_set(csc_set)
.dst_binding(3)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(&cur_info),
],
&[],
);
@@ -2117,6 +2317,13 @@ unsafe fn make_frame(
nv12_view,
cpu_img: None,
cpu_stage: None,
cursor_img,
cursor_mem,
cursor_view,
cursor_stage,
cursor_stage_mem,
cursor_serial: u64::MAX,
cursor_ready: false,
pts_ns: 0,
keyframe: false,
recovery_anchor: false,
@@ -2535,6 +2742,7 @@ mod tests {
pts_ns,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(buf),
cursor: None,
}
}