Skip to content

perf(wgc): encode the helper frame directly and publish it with one copy - #347

Merged
RamazanKara merged 2 commits into
canaryfrom
perf/wgc-direct-encoder-input
Oct 23, 2026
Merged

RamazanKara merged 2 commits into
canaryfrom
perf/wgc-direct-encoder-input

Conversation

@RamazanKara

@RamazanKara RamazanKara commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

co-written by Claude Opus 5.5

This brings Vibepollo#530 to vibeshine canary, rebased onto current canary and reworked after your review there. Vibepollo gets the same change afterwards.

What it does

  1. GPU encoders read the WGC helper's shared frame directly (wgc_direct_encoder_input, default on, fixed for each capture session). Each pool image aliases the helper's shared texture through its own duplicated NT handle. The capture thread only claims the newest frame id, and the encoder takes the keyed mutex on its own device, so the host's snapshot copy and one keyed-mutex hand-off per frame are gone. Turning the option off restores the copy path.
  2. The helper copies once instead of twice when the host is keeping up. When nothing older is queued or being delivered, the D3D context is free and AcquireSync(0, 0) succeeds, the WGC frame goes straight into the shared texture. Otherwise it takes the scratch handoff as before. The helper diagnostics count direct_published.

Your two review points

  • Pixels and timestamps. The conversion reports the frame id and timestamp it actually read under the keyed mutex, and both encode loops use those instead of the claimed ones. A per-encoder-device cursor skips a frame that device already converted, so the same pixels are never encoded twice (two sessions sharing a capture each still get every frame). AMF cancels its reserved input surface on a skip. The choice is one small policy shared by both loops, with unit tests.
  • Context lock during the consumer wait. The delivery thread waits for the shared keyed mutex without holding _d3d_context_mutex, then takes the context only if it's free; if the callback is mid-copy it hands the texture back and waits for the context instead. The frame stays in the delivering state throughout, so publication order holds.

New since the Vibepollo PR

  • Constant-rate capture (wgcc) and PyroWave keep the copy path. wgcc re-sends its cached image on a timeout, which the cursor would drop. PyroWave needs the capture image's own texture and keyed mutex. Both are latched with the input mode.
  • A PyroWave client joining a running direct capture used to receive aliased images. It is now part of the shared capture's configuration, so the existing demand check restarts that capture on the copy path.
  • An unreadable frame record (the helper died mid-publish) skips the frame instead of encoding it with the claimed timestamp. It logs at most once a second, and the broken-pipe handling restarts capture.

Tested

RX 7900 XT, Windows 11, native AMF, loopback Moonlight receiver with a barcode strip, 1920x1080 at 120 fps, physical display, direct input on vs off, alternating runs:

  • Correctness. Wire order clean in every run of both modes: no backwards, repeated or missing frames, no keyed-mutex timeouts, no helper drops. The same with two clients sharing one capture. 16 launches under a GPU load all started (1.7-2.3 s).
  • Host latency. Median about 0.4 ms lower with direct input (HEVC 2.27 vs 2.77 ms, AV1 1.98 vs 2.42 ms).
  • Under a GPU load at 60 fps, picture age p95/p99 were equal within noise (direct 28.4/29.8 ms, copy 27.8/29.2 ms). Both modes drop to about 103 fps there. In 13 runs, repeated pictures followed the helper's publish rate the same way in both modes. The PC was shared with other builds, so I only counted runs where CPU stayed under 25%.
  • Build and tests. Windows CI-style build with -DBUILD_WERROR=ON; each commit compiles on its own. WGC 29/29, video capture source 14/14, video policy 20/20, web unit 91/91. The remaining ctest failures are identical on plain canary. Linux, macOS and FreeBSD builds are left to CI; the Windows-only code stays behind the existing guards.

With WGC capture the helper already publishes every frame into a shared
D3D11 texture, but the host copied it into a pooled image before the
encoder converted it, costing a full-frame GPU copy per frame.

Add wgc_direct_encoder_input (default on, latched per capture session).
When it is active, each pool image aliases the helper's shared texture
through its own duplicated NT handle, opened once per image on the
encoder device. The capture thread only claims the newest frame id; the
encoder takes the shared keyed mutex on its own device, reads the frame
record under it, and converts the texture in place. Switching the option
off restores the copy path.

The encoder pairs the actual converted pixels with their frame id and
timestamp: the helper may publish a newer frame between the claim and
the lock, so the conversion reports the id and timestamp it really read
through converted_frame_t, and the encode loops use those instead of the
claimed ones. A per-encoder-device cursor skips a frame that was already
converted, so the same pixels are never converted or encoded twice; the
AMF device cancels its reserved input surface when it skips. A restarted
helper bumps the frame record generation, which restarts the ids.

Sources that need a stable image of their own keep the copy path:
constant frame rate capture, which re-sends its cached image on a
timeout that the cursor would drop as a repeat, and PyroWave, which pins
and reads the capture image's own texture and keyed mutex. Constant
frame rate is latched with the input mode, so changing the capture
option cannot leave a direct pool with nothing to re-send. PyroWave runs
on the shared capture, so any live PyroWave consumer is part of the
shared capture's configuration: a PyroWave client joining a capture that
already latched direct mode restarts it onto the copy path through the
existing demand check (a PyroWave client leaving does not restart it).
Software encoding and DXGI capture are unaffected.

A direct image whose frame record cannot be read is skipped like a
repeat, without touching the frame cursor, and logged at most once a
second. That only happens if the helper died mid-publish, and the IPC
session's broken-pipe handling restarts capture in that case. The
timestamp choice and the skip of an already-converted frame are one
policy shared by both encode loops, and only frames actually converted
are counted as such.

Signed-off-by: Ramazan Kara <github@r-kara.de>
…ng up

The helper copied every WGC frame into a scratch texture and then, on
its delivery thread, from the scratch texture into the shared texture
the host reads, so each frame cost two GPU copies even when nothing was
queued.

When no older frame is queued or mid-delivery, the capture callback now
copies the WGC frame straight into the shared texture, as long as the
helper's D3D context is free and the keyed mutex can be acquired without
waiting. Otherwise the frame takes the scratch handoff as before, so a
newer frame is never published ahead of an older one. The helper counts
direct publications in its periodic diagnostics.

The scratch fallback no longer holds the D3D context while it waits for
the host. The delivery thread used to hold the context lock for up to
200 ms waiting on the shared keyed mutex, and the capture callback needs
that lock for its scratch copy, so encoder backpressure could stall
FrameArrived. It now waits for the keyed mutex alone and then takes the
context only if it is free; if the callback is mid-copy, it hands the
texture back and waits for the context instead. Neither side holds one
lock while waiting on the other, and the frame stays in the delivering
state throughout, which keeps the callback from publishing a newer frame
ahead of it.

Signed-off-by: Ramazan Kara <github@r-kara.de>
@RamazanKara
RamazanKara merged commit bb02a93 into canary Oct 23, 2026
17 checks passed
@RamazanKara
RamazanKara deleted the perf/wgc-direct-encoder-input branch October 23, 2026 00:39
RamazanKara added a commit to RamazanKara/Vibepollo that referenced this pull request Oct 23, 2026
With WGC capture the helper already publishes every frame into a shared
D3D11 texture, but the host copied it into a pooled image before the
encoder converted it, costing a full-frame GPU copy per frame.

Add wgc_direct_encoder_input (default on, latched per capture session).
When it is active, each pool image aliases the helper's shared texture
through its own duplicated NT handle, opened once per image on the
encoder device. The capture thread only claims the newest frame id; the
encoder takes the shared keyed mutex on its own device, reads the frame
record under it, and converts the texture in place. Switching the option
off restores the copy path.

The encoder pairs the actual converted pixels with their frame id and
timestamp: the helper may publish a newer frame between the claim and
the lock, so the conversion reports the id and timestamp it really read
through converted_frame_t, and the encode loops use those instead of the
claimed ones. A per-encoder-device cursor skips a frame that was already
converted, so the same pixels are never converted or encoded twice; the
AMF device cancels its reserved input surface when it skips. A restarted
helper bumps the frame record generation, which restarts the ids.

Sources that need a stable image of their own keep the copy path:
constant frame rate capture, which re-sends its cached image on a
timeout that the cursor would drop as a repeat, and PyroWave, which pins
and reads the capture image's own texture and keyed mutex. Constant
frame rate is latched with the input mode, so changing the capture
option cannot leave a direct pool with nothing to re-send. PyroWave runs
on the shared capture, so any live PyroWave consumer is part of the
shared capture's configuration: a PyroWave client joining a capture that
already latched direct mode restarts it onto the copy path through the
existing demand check (a PyroWave client leaving does not restart it).
Software encoding and DXGI capture are unaffected.

A direct image whose frame record cannot be read is skipped like a
repeat, without touching the frame cursor, and logged at most once a
second. That only happens if the helper died mid-publish, and the IPC
session's broken-pipe handling restarts capture in that case. The
timestamp choice and the skip of an already-converted frame are one
policy shared by both encode loops, and only frames actually converted
are counted as such.

Vibepollo stamps frames before converting them, so both encode loops now
replace capture_timestamp and host_processing_timestamp with the
converted frame's stamps before the pacing stamp is derived from them
(the async loop's frame_variation smoothing then starts from the
converted frame). capture_pacing_timestamp stays the claim time, since it
anchors the capture loop's pacing rather than the frame. The demand
check keeps Vibepollo's pacing context argument.

A repeated direct frame is skipped, which must not delay a requested
keyframe until the next capture. The async loop carries the request into
the next iteration (retry_idr), which encodes the last input as the IDR
without waiting; the sync loop encodes the last input as the IDR in the
same pass, unstamped like the no-new-frame path. The decision is
video::policy::retry_idr_after_repeat.

This is the Vibepollo port of Nonary/vibeshine#347 and the follow-up fix.

Signed-off-by: Ramazan Kara <github@r-kara.de>
RamazanKara added a commit to RamazanKara/Vibepollo that referenced this pull request Oct 23, 2026
…ng up

The helper copied every WGC frame into a scratch texture and then, on
its delivery thread, from the scratch texture into the shared texture
the host reads, so each frame cost two GPU copies even when nothing was
queued.

When no older frame is queued or mid-delivery, the capture callback now
copies the WGC frame straight into the shared texture, as long as the
helper's D3D context is free and the keyed mutex can be acquired without
waiting. Otherwise the frame takes the scratch handoff as before, so a
newer frame is never published ahead of an older one. The helper counts
direct publications in its periodic diagnostics.

The scratch fallback no longer holds the D3D context while it waits for
the host. The delivery thread used to hold the context lock for up to
200 ms waiting on the shared keyed mutex, and the capture callback needs
that lock for its scratch copy, so encoder backpressure could stall
FrameArrived. It now waits for the keyed mutex alone and then takes the
context only if it is free; if the callback is mid-copy, it hands the
texture back and waits for the context instead. Neither side holds one
lock while waiting on the other, and the frame stays in the delivering
state throughout, which keeps the callback from publishing a newer frame
ahead of it.

This is the Vibepollo port of Nonary/vibeshine#347.

Signed-off-by: Ramazan Kara <github@r-kara.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant