Skip to content

Virtual camera

NuclearMeltdown edited this page Aug 24, 2026 · 7 revisions

Virtual camera

src/vcam/vcam_shared.h, virtual_camera.cpp, vcam_source.cpp, vcam_dll.cpp, capview_vcam.def

The picture can be offered to other programs as a webcam — Discord, OBS, Teams, a browser — under the name CapView. Switched on in Settings → Recording, and it exists only while CapView is running.

Why Media Foundation and not DirectShow

Because it reaches strictly more programs. Measured on one machine with several virtual cameras installed:

DirectShow Media Foundation
OBS Virtual Camera yes no
Camera (NVIDIA Broadcast) yes no
CapView yes yes
real cameras (webcam, capture card) yes yes

A DirectShow virtual camera is genuinely absent from Media Foundation, while one registered through MFCreateVirtualCamera appears in both — Windows bridges frame-server cameras into DirectShow enumeration, and not the other way about.

That does not mean a DirectShow camera reaches nothing modern. Chromium enumerates both backends, so Discord, Chrome and Edge see OBS's camera perfectly well. What a DirectShow camera cannot reach is anything that goes only through the frame server: the Windows Camera app, the camera list in Settings, packaged apps, Windows Hello, and the current Teams.

Media Foundation is the superset, which is the whole of the argument.

The cost is Windows 11 (build 22000), where MFCreateVirtualCamera first appeared. There is no equivalent before it, and Windows 10 is not supported.

Installation

A separate, one-time step with a UAC prompt, with an uninstall button next to it.

The reason is structural rather than fussy: Windows loads the camera's media source into the Frame Server service, not into CapView, so it has to be registered machine-wide. A per-user registration would not be visible to the account that has to load it.

Using the camera afterwards needs no rights at all.

VirtualCamera::InstallSource():

if (!WriteMediaSource(error)) return false;
if (!RunRegsvr(false, error)) return false;
CleanUpOldSources();
return true;

RunRegsvr() runs regsvr32 through ShellExecuteEx with the runas verb, which is what raises the prompt. UninstallSource() is the same call with /u.

The DLL is inside the executable

The media source has to be a separate DLL because Windows loads it into a service. It is not a separate download: it travels inside CapView.exe as an RT_RCDATA resource (kMediaSourceResourceId = 101) and is written out when the camera is installed.

A release is one file, and the executable can only ever install a source that matches itself.

capview_vcam.def exists because STDAPI carries no dllexport — without the module definition file the DLL exported nothing at all and regsvr32 had nothing to call.

The DLL name carries a hash of its own contents

uint64_t hash = 1469598103934665603ull;         // FNV-1a
for (DWORD i = 0; i < size; ++i) hash = (hash ^ data[i]) * 1099511628211ull;
swprintf(buffer, 64, L"capview_vcam_%016llx.dll", hash);

This is not tidiness. It is the whole mechanism, and it took a long time to arrive at.

Windows identifies a loaded module inside a process by its file name, not by its path. The frame server is a service that stays running with the source mapped, and a later LoadLibrary of a different file with the same name hands back the module already loaded rather than reading the new one.

Renaming the old file out of the way does not help either — measured: a loaded module keeps its pages, not its directory entry.

Between them those two facts made this look unfixable from the outside. Installing appeared to work, regsvr32 reported success, and the old code kept answering. Every symptom pointed at the new DLL being broken; nothing pointed at it never being read.

A name that changes with the contents cannot be confused with anything, and an install becomes idempotent: the file for this build either is already there or is not.

Three supporting pieces:

  • WriteMediaSource() still has to deal with a file in use. Windows loads the DLL into the frame server and into CapView itself the moment the camera is created — measured, both hold it, and closing CapView does not release it. It cannot be overwritten; it can be renamed. So the old one is moved aside, the new one takes its name, and the leftover goes at some later start.
  • DllRegisterServer stops the frame server (StopFrameServer() in vcam_dll.cpp) — ControlService(SERVICE_CONTROL_STOP) and then up to four seconds of polling. regsvr32 already runs elevated at that point, so it is the one place where stopping a service costs nothing extra. Whatever the service had mapped is gone afterwards.
  • CleanUpOldSources() sweeps every file matching the name pattern except the one this build uses. Anything still mapped simply refuses to delete and gets another chance at a later start.

RegisteredPath() reads back what the registration actually points at, so "installed" means the file is there rather than merely that a key exists. A build that was moved leaves exactly that trap behind.

The shared section

kSectionName = L"Global\\CapViewVirtualCameraFrames"

The two halves do not run in the same place. CapView runs as the logged-in user; the media source is loaded by the Frame Server, which is a service in session 0. So the pictures travel through a named section in the global object namespace.

The media source creates it and CapView opens it. Creating anything under Global\ needs SeCreateGlobalPrivilege, which an ordinary user account does not have and a service does.

That ordering is not a workaround. The section is only wanted while something is actually consuming the camera, and that is exactly when the media source exists — so CapView does no work at all until somebody opens it.

Layout

struct SharedState {
  uint32_t magic;        // 'CVVC'
  uint32_t version;      // kVersion = 3
  uint32_t stateBytes;   // sizeof(SharedState) as the creator understood it

  volatile uint32_t wantWidth, wantHeight, wantFps, wantPixel;  // source → CapView
  volatile uint32_t consumers;
  volatile uint32_t writeIndex, producerAlive;                  // CapView → source
  volatile uint32_t sourceStarted, samplesServed, framesTaken;  // counters
  SlotHeader slots[3];
};

kSlotDataOffset = 4096, then three slots of 1920 × 1080 × 3 bytes each — enough for P010 as well as NV12.

The seqlock

struct alignas(64) SlotHeader {
  volatile uint32_t sequence;   // odd while being written, even when whole
  uint32_t width, height, bytes, pixel;
  int64_t timestamp100ns;
};

A reader that sees the same even value before and after copying knows the copy is not torn. Neither side ever waits for the other, and there is no shared lock to leak across a session boundary.

Three slots is enough that a reader never waits on the writer: one being written, one being read, one spare — the same shape as the capture sink.

kFrameEventName is raised when a picture is published so the media source can wait instead of spinning. It is not required for correctness: a reader that misses it simply serves the previous picture.

Version checking

kVersion is raised whenever anything in the header changes shape, and both halves check it.

This matters because the executable and the media source are installed separately — the update check replaces only the executable, and Windows keeps the DLL locked while a camera is in use — so the two genuinely can end up from different builds. Left undetected that produces a black picture and no explanation, which is the worst kind of failure. Detected, it is one sentence telling the user to install the camera again.

The counters

sourceStarted, samplesServed and framesTaken exist so the half that runs inside a service can say what it is doing without needing somewhere to write a log. CapView reads them out and the statistics overlay shows them. They are what diagnosed the pacing bug below.

The media source

vcam_source.cpp. Implements IMFMediaSourceEx, IMFMediaStream2, IKsControl — and IMFActivate.

IMFActivate is documented as optional and is not. Without it, MFCreateVirtualCamera returns E_NOINTERFACE and says nothing more. All thirty IMFAttributes methods have to be implemented along with it.

IKsControl returns HRESULT, not NTSTATUS, whatever the surrounding kernel streaming documentation suggests.

Sample pacing — the bug that produced black frames

The final black-frame cause was that the source served samples as fast as it was asked for them: measured at over 2000 a second for a 30 fps camera. Consumers saw a stream that made no sense and showed nothing.

STDMETHODIMP VCamStream::RequestSample(IUnknown* token) {
  DWORD sleepFor = 0;
  { std::lock_guard<std::mutex> lock(mutex_);
    if (shutdown_) return MF_E_SHUTDOWN;
    if (state_ != MF_STREAM_STATE_RUNNING) return MF_E_INVALIDREQUEST;
    const DWORD period = 1000 / (format_.fps ? format_.fps : 30);
    const DWORD now = ::GetTickCount();
    if (nextDue_ == 0) nextDue_ = now;
    else if ((int)(nextDue_ - now) > 0) {
      sleepFor = (DWORD)((int)(nextDue_ - now));
      if (sleepFor > 250) sleepFor = 250;
    }
    nextDue_ = now + period;
  }
  if (sleepFor > 0) ::Sleep(sleepFor);
  …

The sleep happens outside the lock, and the 250 ms ceiling stops a misconfigured rate from wedging the pipeline.

The other half of the fix was restating the wanted format on every sample rather than announcing it once at the start.

Formats offered

Resolution Rate Pixel format
1920×1080 30 NV12, plus P010 when asked for
1280×720 30 NV12, plus P010 when asked for
640×480 30 NV12, plus P010 when asked for

NV12 throughout, because that is what the frame server and nearly every consumer want, and offering one layout means there is no conversion to get wrong.

The consumer picks, and whatever it picks is what CapView renders into. ConvertLetterboxed() (RGBA → NV12) and ConvertLetterboxedP010() keep the picture's shape and fill the rest with black — a 4:3 console in a 16:9 camera is pillarboxed rather than stretched.

The HDR switch

Off by default and deliberately so: something that takes the ten bits without understanding them shows a wrong picture, so the wide format is offered only when asked for. Today almost nothing on the other end knows what to do with an HDR webcam.

The switch has to cross from CapView into a DLL running inside a service, and the media source needs to know before the shared section exists — it builds its list of formats when it is created. So it is the plainest thing that works across that boundary: a file in ProgramData\CapView\virtual-camera-hdr that is either there or not (SetWideOffered()). ProgramData because both accounts can reach it.

Known behaviour

Slightly choppy in Discord. Discord's own handling of virtual cameras, not CapView's pacing — the same source is smooth in OBS on the same machine at the same time.

Clone this wiki locally