Skip to content

perf(windows): keep WGC recording frames on the GPU#114

Closed
YoneRai12 wants to merge 1 commit into
getopenscreen:mainfrom
YoneRai12:agent/windows-wgc-gpu-zero-copy
Closed

perf(windows): keep WGC recording frames on the GPU#114
YoneRai12 wants to merge 1 commit into
getopenscreen:mainfrom
YoneRai12:agent/windows-wgc-gpu-zero-copy

Conversation

@YoneRai12

@YoneRai12 YoneRai12 commented Jul 18, 2026

Copy link
Copy Markdown

Summary

  • Feed Windows Graphics Capture D3D11 textures directly to Media Foundation through DXGI surface buffers and an IMFDXGIDeviceManager.
  • Keep a bounded texture ring alive until asynchronous sink-writer markers confirm that each sample is consumed.
  • Preserve the existing CPU staging/readback path as a runtime fallback, including injected-failure coverage.
  • Report the selected H.264 encoder, frame transport, GPU/CPU frame counts, elapsed time, and process CPU time.
  • Crop odd capture sizes to valid even H.264 dimensions and keep the webcam encoder on its independent path.

Why

The previous Windows path copied every WGC frame from a GPU texture to a staging texture, mapped it, and copied every row into a fresh Media Foundation memory buffer. At 3840x2160 and 60 fps, that is about 1.99 GB/s of avoidable GPU-to-CPU traffic before encoding begins.

Validation

Same-machine 3840x2160@60, 5-second comparison:

Transport Delivered frames Process CPU time
GPU zero-copy 285 328 ms
Forced CPU readback 176 3234 ms

That reduced process CPU time by about 89.9% and delivered about 61.9% more frames under the same conditions.

Also verified:

  • 4K60 screen capture with system audio: hardware H.264 + AAC, 0 CPU-readback frames.
  • All three local monitors, including a 2160x3840 portrait monitor.
  • Odd-sized window capture (3056x1646).
  • Separate webcam output while screen capture remains GPU zero-copy.
  • Injected GPU-frame transport failure falls back to a valid CPU-readback MP4.
  • Injected default sink-writer failure falls back to the Microsoft software H.264 encoder.
  • Native helper build succeeds.
  • npx vitest --run --testTimeout=15000: 51 files, 407 tests passed.
  • npm run build-vite succeeds.

Summary by CodeRabbit

  • New Features

    • Windows recording now uses GPU zero-copy frame transport by default when available, improving capture efficiency.
    • Recording automatically falls back to CPU frame processing if GPU transport is unavailable or fails.
    • Windows recording results now report encoder selection and frame transport details for diagnostics.
    • Added timing and frame-processing summaries to capture diagnostics.
  • Bug Fixes

    • Software encoder notices now appear consistently when either default or fallback software encoding is selected.
    • Improved handling of encoder finalization failures during recording.

@YoneRai12
YoneRai12 requested a review from EtienneLescot as a code owner July 18, 2026 12:32
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Windows native recording now supports GPU zero-copy frame transport with CPU readback fallback, encoder and transport telemetry, expanded IPC/UI reporting, stronger finalization handling, configurable GPU failure injection, and broader smoke-test validation.

Changes

Windows GPU frame transport

Layer / File(s) Summary
Encoder contracts and sink-writer setup
electron/native/wgc-capture/src/mf_encoder.h, electron/native/wgc-capture/src/mf_encoder.cpp
MFEncoder adds GPU transport options, transport/frame counters, asynchronous sink-writer callbacks, and encoder-selection detection.
GPU pipeline and CPU fallback
electron/native/wgc-capture/src/mf_encoder.cpp
Frames use a D3D11 texture ring and DXGI samples when available, then fall back to CPU readback after GPU failures or timeouts.
Native capture integration and telemetry
electron/native/wgc-capture/src/main.cpp
The helper crops copied textures to even H.264 dimensions, configures webcam transport behavior, reports encoder/frame summaries, and checks finalize results.
IPC and recording result contract
electron/ipc/handlers.ts, src/lib/nativeWindowsRecording.ts, src/hooks/useScreenRecorder.ts
Windows recording results expose videoFrameTransport, and software notices cover both default and fallback software encoder selections.
Smoke tests and helper documentation
scripts/test-windows-wgc-helper.mjs, electron/native/README.md
Tests validate encoder selection, transport fallback, counters, timings, and injected failures; documentation describes the new pipeline and commands.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CaptureHelper
  participant MFEncoder
  participant SinkWriter
  participant IPC
  participant ScreenRecorder
  CaptureHelper->>MFEncoder: initialize encoder and GPU pipeline
  CaptureHelper->>MFEncoder: submit capture texture
  MFEncoder->>SinkWriter: write GPU DXGI sample
  SinkWriter-->>MFEncoder: report marker or finalize status
  MFEncoder-->>CaptureHelper: report encoder summary
  CaptureHelper-->>IPC: return encoder selection and frame transport
  IPC-->>ScreenRecorder: expose recording result
Loading

Possibly related PRs

  • getopenscreen/openscreen#73: Extends the same Windows recording encoder-selection parsing and IPC response path with software encoder preference and selection reporting.

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: keeping WGC recording frames on the GPU.
Description check ✅ Passed The description covers summary, motivation, and validation clearly, though several optional template sections are left blank.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/native/wgc-capture/src/main.cpp`:
- Around line 632-648: Update the copy setup surrounding sourceBox and
CopySubresourceRegion to retrieve the incoming texture’s actual D3D11
description, then clamp the box width and height to its dimensions before
copying. Preserve the existing copy behavior while ensuring sourceBox never
exceeds texture’s bounds during window resizing.

In `@electron/native/wgc-capture/src/mf_encoder.cpp`:
- Around line 1004-1008: Update the WriteSample(DXGI) failure branch in the
encoder flow to match the other GPU failure handlers: disable GPU transport, set
retryWithCpu, and then return false after logging the error. Preserve the
existing HRESULT logging and ensure subsequent frames can switch to the CPU
path.

In `@scripts/test-windows-wgc-helper.mjs`:
- Around line 449-458: Update the encoder summary validation condition to
require both encoderSummary.gpuFrames and encoderSummary.cpuFrames to be
non-negative before evaluating their total, while preserving the existing
integer and transport checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f942bf5b-c749-42e4-b51e-2f79bbce502a

📥 Commits

Reviewing files that changed from the base of the PR and between d5966ed and 8271544.

📒 Files selected for processing (8)
  • electron/ipc/handlers.ts
  • electron/native/README.md
  • electron/native/wgc-capture/src/main.cpp
  • electron/native/wgc-capture/src/mf_encoder.cpp
  • electron/native/wgc-capture/src/mf_encoder.h
  • scripts/test-windows-wgc-helper.mjs
  • src/hooks/useScreenRecorder.ts
  • src/lib/nativeWindowsRecording.ts

Comment on lines +632 to +648
const D3D11_BOX sourceBox{
0,
0,
0,
static_cast<UINT>(width),
static_cast<UINT>(height),
1,
};
session.context()->CopySubresourceRegion(
latestFrameTexture.Get(),
0,
0,
0,
0,
texture,
0,
&sourceBox);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clamp the subresource box to the incoming texture dimensions.

If the captured window is resized smaller than its original dimensions, the incoming WGC texture will be smaller than width or height. When sourceBox extends beyond the source texture's bounds, D3D11's CopySubresourceRegion silently drops the operation, causing the recording to freeze.

Fetch the incoming texture's actual description and clamp the copy bounds to prevent this.

🐛 Proposed fix to prevent frozen frames on window resize
-        const D3D11_BOX sourceBox{
-            0,
-            0,
-            0,
-            static_cast<UINT>(width),
-            static_cast<UINT>(height),
-            1,
-        };
+        D3D11_TEXTURE2D_DESC srcDesc{};
+        texture->GetDesc(&srcDesc);
+        const D3D11_BOX sourceBox{
+            0,
+            0,
+            0,
+            std::min(static_cast<UINT>(width), srcDesc.Width),
+            std::min(static_cast<UINT>(height), srcDesc.Height),
+            1,
+        };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const D3D11_BOX sourceBox{
0,
0,
0,
static_cast<UINT>(width),
static_cast<UINT>(height),
1,
};
session.context()->CopySubresourceRegion(
latestFrameTexture.Get(),
0,
0,
0,
0,
texture,
0,
&sourceBox);
D3D11_TEXTURE2D_DESC srcDesc{};
texture->GetDesc(&srcDesc);
const D3D11_BOX sourceBox{
0,
0,
0,
std::min(static_cast<UINT>(width), srcDesc.Width),
std::min(static_cast<UINT>(height), srcDesc.Height),
1,
};
session.context()->CopySubresourceRegion(
latestFrameTexture.Get(),
0,
0,
0,
0,
texture,
0,
&sourceBox);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/main.cpp` around lines 632 - 648, Update the
copy setup surrounding sourceBox and CopySubresourceRegion to retrieve the
incoming texture’s actual D3D11 description, then clamp the box width and height
to its dimensions before copying. Preserve the existing copy behavior while
ensuring sourceBox never exceeds texture’s bounds during window resizing.

Comment on lines +1004 to +1008
hr = sinkWriter_->WriteSample(videoStreamIndex_, sample.Get());
if (FAILED(hr)) {
std::cerr << "ERROR: WriteSample(DXGI) failed (hr=0x"
<< std::hex << hr << std::dec << ")" << std::endl;
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C6 \
  'WriteSample\(DXGI\)|retryWithCpu|disableGpuFrameTransport|injectGpuFrameFailure' \
  electron/native/wgc-capture/src/mf_encoder.cpp

Repository: getopenscreen/openscreen

Length of output: 6899


🏁 Script executed:

#!/bin/bash
sed -n '860,1120p' electron/native/wgc-capture/src/mf_encoder.cpp

Repository: getopenscreen/openscreen

Length of output: 8833


Handle WriteSample(DXGI) like the other GPU failures. This is the only GPU write path that returns false without disabling GPU transport or setting retryWithCpu, so a DXGI WriteSample error aborts the frame and keeps the next frames on the broken GPU path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/mf_encoder.cpp` around lines 1004 - 1008,
Update the WriteSample(DXGI) failure branch in the encoder flow to match the
other GPU failure handlers: disable GPU transport, set retryWithCpu, and then
return false after logging the error. Preserve the existing HRESULT logging and
ensure subsequent frames can switch to the CPU path.

Comment on lines +449 to +458
if (
!encoderSummary ||
!["gpu-zero-copy", "cpu-readback"].includes(encoderSummary.frameTransport) ||
!Number.isInteger(encoderSummary.gpuFrames) ||
!Number.isInteger(encoderSummary.cpuFrames) ||
!Number.isInteger(encoderSummary.elapsedMs) ||
!Number.isInteger(encoderSummary.processCpuTimeMs) ||
encoderSummary.elapsedMs <= 0 ||
encoderSummary.processCpuTimeMs < 0 ||
encoderSummary.gpuFrames + encoderSummary.cpuFrames <= 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject negative frame counters in the encoder summary.

The current sum check accepts malformed telemetry such as gpuFrames: -1, cpuFrames: 2. Validate both counters as non-negative before using their total.

Proposed fix
 	!Number.isInteger(encoderSummary.gpuFrames) ||
 	!Number.isInteger(encoderSummary.cpuFrames) ||
+	encoderSummary.gpuFrames < 0 ||
+	encoderSummary.cpuFrames < 0 ||
 	!Number.isInteger(encoderSummary.elapsedMs) ||
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
!encoderSummary ||
!["gpu-zero-copy", "cpu-readback"].includes(encoderSummary.frameTransport) ||
!Number.isInteger(encoderSummary.gpuFrames) ||
!Number.isInteger(encoderSummary.cpuFrames) ||
!Number.isInteger(encoderSummary.elapsedMs) ||
!Number.isInteger(encoderSummary.processCpuTimeMs) ||
encoderSummary.elapsedMs <= 0 ||
encoderSummary.processCpuTimeMs < 0 ||
encoderSummary.gpuFrames + encoderSummary.cpuFrames <= 0
if (
!encoderSummary ||
!["gpu-zero-copy", "cpu-readback"].includes(encoderSummary.frameTransport) ||
!Number.isInteger(encoderSummary.gpuFrames) ||
!Number.isInteger(encoderSummary.cpuFrames) ||
encoderSummary.gpuFrames < 0 ||
encoderSummary.cpuFrames < 0 ||
!Number.isInteger(encoderSummary.elapsedMs) ||
!Number.isInteger(encoderSummary.processCpuTimeMs) ||
encoderSummary.elapsedMs <= 0 ||
encoderSummary.processCpuTimeMs < 0 ||
encoderSummary.gpuFrames + encoderSummary.cpuFrames <= 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-windows-wgc-helper.mjs` around lines 449 - 458, Update the
encoder summary validation condition to require both encoderSummary.gpuFrames
and encoderSummary.cpuFrames to be non-negative before evaluating their total,
while preserving the existing integer and transport checks.

@EtienneLescot

Copy link
Copy Markdown
Collaborator

Hi @YoneRai12, Very good initiative. Thank you!
I am closing it thought because a massive native refacto is beeing made in the ai-edition branch.
Improving the perf x16 ;-)

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.

2 participants