Skip to content

Virtual camera

NuclearMeltdown edited this page Aug 26, 2026 · 7 revisions

Virtual camera

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

The picture can be offered to other programs as a webcam — OBS, Discord, a browser — under the name CapView Virtual Camera. Switched on in Settings → Recording. The filter is registered machine-wide, so the camera stays in every device list from the moment it is installed, whether CapView is running or not; while nothing is feeding it, it shows the idle picture.

In 3.0 it delivers the source 1:1: the source's own resolution at the source's own frame rate. Consumers that cannot take that get what they ask for instead, scaled inside their own process.

Why DirectShow, having argued for Media Foundation

This page used to make the opposite case, and the case was not wrong. Media Foundation genuinely is the superset: a camera registered through MFCreateVirtualCamera appears in both backends, because Windows bridges frame-server cameras into DirectShow enumeration and not the other way about. A DirectShow camera cannot reach the Windows Camera app, the camera list in Settings, packaged apps, Windows Hello, or the current Teams.

What the argument left out is what it cost, which only became clear once the thing was finished and in daily use:

Media Foundation (2.x) DirectShow (3.0)
Where the camera code runs Frame Server service, session 0 inside the consuming program
Shared objects Global\, needs SeCreateGlobalPrivilege Local\, needs nothing
Formats offered 1080p, 720p, 480p — all at 30 the source's own, plus the ordinary sizes below it
Updating the DLL stop a system service replace a file
Windows version 11 (build 22000) and later anything CapView runs on
Reaches packaged apps yes no

The fixed list is the one that mattered. A 576i50 SNES came out of the 2.x camera upscaled to 1080p and stuttering at 30, and there was nowhere in that design to put the real answer, because a Media Foundation source has to declare a finite list of media types up front. DirectShow does not: IAMStreamConfig advertises ranges, and a consumer states what it wants rather than picking from a menu.

Running in the consumer's process is the second thing. The camera is the same user in the same session as the program reading it now, so Global\ goes away along with the privilege it needed, the frame server goes away along with the service restarts, and Windows 11 stops being a requirement.

The price is app containers. Local\ names are invisible inside one, so Store apps do not see the camera. That is a deliberate trade: nothing anyone wants to feed a capture card into is a Store app, and the alternative was asking every user for administrator rights on every start.

The source, 1:1

Pin::BuildTypeList() puts the source's own shape first:

if (srcPixel == kPixelP010) add(kPixelP010, srcW, srcH);
add(kPixelNv12, srcW, srcH);

A consumer that takes the first thing offered — OBS does — gets the picture untouched. There is no scaling step at all in that case: ScalePlane takes a memcpy fast path when source and destination are the same size.

A consumer that asked for something smaller gets an area average rather than two taps. Two taps read a pair of source pixels and skip whatever lies between them, and at a six-to-five shrink the sampling phase repeats as 0.1, 0.3, 0.5, 0.7, 0.9 — every fifth pixel nearly a copy of one source pixel, the next a half-and-half blend. That alternation is what grains. ShrinkPlane covers the whole footprint of each destination pixel, weighted in 16.16 by how much of each source pixel it overlaps, so there is no phase to beat against. It costs about (srcW + dstW) × (srcH + dstH) samples against two taps' 4 × dstW × dstH — more, not less, and worth it. Enlarging still uses two taps: a footprint narrower than one source pixel averages nothing.

It does not make a small picture out of a large one for free. At a six-to-five shrink the footprint is 1.2 source pixels wide, which cannot average away detail at the Nyquist limit and is not meant to — an alternating black and white column pattern comes out 43 to 213 instead of two taps' 25 to 230. What changes is that every destination pixel is now treated the same way.

Then the ordinary sizes follow, for consumers that read the list instead of negotiating. Chromium does that, which means Discord and every browser do:

1920×1080  1280×720  960×540  854×480  640×480  640×360  320×240

1600×900 was in that list and is not any more. Nothing has it on a wish list, and from a 1080p source it is a six-to-five shrink — the ratio at which a resampler is most visible. It was the one size below 1080p that grained.

Nothing larger than the source is offered, and there is no exception. 720p used to be in the list even from a 576i source, on the reasoning that a consumer with a fixed wish list needs its wish to be present. What that actually bought was a 576i console permanently displayed at 720p: a program picks the largest entry it likes and then keeps that choice for as long as it holds the camera. Discord did exactly that. Offering only what exists costs such a consumer nothing — it takes the next size down, or upscales at its own end, where the work belongs.

While CapView is not running there is no source to be smaller than, so the list is offered whole. A consumer that opens the camera before CapView starts should not be pinned to the stand-in's shape once a real picture arrives.

The capabilities

GetStreamCaps fills a VIDEO_STREAM_CONFIG_CAPS alongside each type, and each one describes that type and nothing else:

c->MinOutputSize      = { w, h };          // this entry's own size,
c->MaxOutputSize      = { w, h };          // both ends
c->OutputGranularityX = 2;                 // chroma is half resolution
c->OutputGranularityY = 2;
c->MinFrameInterval   = srcInterval;       // fastest: the source's own rate
c->MaxFrameInterval   = kSlowestInterval;

There is no ceiling of its own — the largest entry follows the source. Put 8K at 120 in front of it and 8K at 120 is what it advertises.

It used to advertise a size range per entry instead, 32×32 up to max(srcW, 1920) × max(srcH, 1080), on the reasoning that a consumer which reads ranges could then ask for anything without finding it in a list. The reasoning was sound and the result was not, because OBS reads those bounds straight into its resolution dropdown. From a 576i console the list therefore offered 32×32 at one end and 1920×1080 at the other, neither of which the camera had, and both of which survived restarting OBS — they were never a description of the source, so nothing about the source could change them.

Asking for a size that is not advertised still works: QueryAccept takes anything from 32×32 up to the source's size, or 1920×1080 when the source is smaller. Advertise narrowly, accept broadly.

MinFrameInterval is the source's interval rather than something invented: the camera will divide a rate down but never make frames up.

A consumer keeps the filter it loaded

Windows identifies a loaded module by name, which is why the DLL's filename carries a hash of its contents — installing a new CapView lays down a new file rather than replacing one that may be in use. The other half of that is worth knowing: a program that was already running when the camera was installed or updated keeps the filter it loaded until it is restarted. It is not a stale picture but stale code, so it can show behaviour that no longer exists in the build sitting on disk. Restarting the consuming program is the whole fix.

What a consumer actually gets

Measured with capview_vcam_test (below), against a source at 1920×1080 / 59.94:

Asked for Delivered Samples in 5 s
nothing (first offer) 1920×1080 @ 59.94 301 — 59.94 fps
1280×720 @ 30 1280×720 @ 30 151 — 30.00 fps
854×480 @ 24 854×480 @ 24 97 — 24.00 fps
640×480 @ 15 640×480 @ 15, pillarboxed 61 — 15.00 fps

Three of those running at once, each at its own size and its own rate, is the normal case rather than a stress test — there is one filter instance per consumer and they share nothing but the pictures.

A format is settled once, when the camera is opened

This is the part that surprises people, and it is DirectShow's rule rather than CapView's. A consumer connects, the two agree a media type, and the allocator commits buffers of exactly that size. From then on the shape is fixed for as long as that connection lives. There is no way to grow it underneath a running graph: the buffers the consumer already holds are the size they were agreed at, and a larger picture would not fit in them.

So swapping a 1080p console for a 576i one does everything on CapView's side immediately — a new frame section, a new generation, the new shape published — and changes nothing on the consumer's side. It goes on asking for what it negotiated, and the filter goes on fitting the new picture into it, pillarboxed. The camera is not stuck; the connection is.

Reopening the camera in the consumer is the whole fix, and the way to never need it is to let the consumer choose: leave the resolution on automatic in whatever program reads the camera, and do not pick a size by hand. A size picked by hand is a size that stays picked. Worth knowing per program:

  • OBS — a Video Capture Device has Resolution/FPS Type. On Custom it asks for the resolution written in the box and will keep asking for it forever. On Device Default it takes the first thing the camera offers, which is the source's own shape. Set it to Device Default and it follows the console; leave it on Custom and it never will. What Custom offers is now only what the camera actually has — that is what narrowing the capabilities above bought — so a stale 1080p in that box no longer has a matching entry to keep it looking legitimate. What OBS shows in that box is its own cache, filled when the box was last opened: swap down from 1080p to 576i and the 1080p-era sizes stay listed until OBS is restarted. Disabling and re-enabling the device does not clear it. Nothing on this side is cached — every GetNumberOfCapabilities and GetStreamCaps is rebuilt from the live control block, and MediaTypeEnumerator::Reset re-derives rather than rewinds — which is why the list is right again the moment OBS starts fresh. On Device Default the cache never matters.
  • Discord — settles a format when the camera goes live and holds it for that call. Turning the camera off and on again in Discord renegotiates. Observed in the consumer table asking for 1280×720 at 60, not the 30 assumed here before anyone had watched it. From a 576i console there is no 720p entry to find any more, so it takes 720×576 and the upscale stops happening at all.
  • Browsers — take whatever the page's getUserMedia constraints ask for, per getUserMedia call.

Nothing here can be fixed from CapView's side, which is worth stating plainly because the symptom looks exactly like a bug in the camera: CapView's own window shows the new console correctly while OBS shows it letterboxed into the old size.

Installation

A one-time step with a UAC prompt, with an uninstall button next to it. Using the camera afterwards needs no rights at all.

The reason is narrower than it was: a DirectShow filter is found through HKEY_CLASSES_ROOT\CLSID and the filter mapper, both of which are machine-wide. Nothing has to run as a service any more.

if (!WriteFilterDll(error)) return false;
if (!RunRegsvr(false, error)) return false;
CleanUpOldSources();

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

DllRegisterServer writes the class, its InprocServer32, and an IFilterMapper2::RegisterFilter entry under CLSID_VideoInputDeviceCategory. The merit is MERIT_DO_NOT_USE, which is what every virtual camera uses: it keeps the entry in the device list where a user can choose it, while stopping the graph builder from wiring it into unrelated graphs on its own initiative.

A per-user install would work, and is not what 3.0 does

The UAC prompt is avoidable. COM resolves a class through HKEY_CLASSES_ROOT, which is a merged view: HKEY_CURRENT_USER\Software\Classes shadows HKEY_LOCAL_MACHINE\Software\Classes. Writing the class, its InprocServer32 and an Instance entry under the video-input category into the per-user hive needs no rights at all, and it was tried:

[4] CapView Virtual Camera
    DevicePath : @device:sw:{860BB310-...}\{A326E6EC-...}
    Gemeldete Capability-Entraege: 6

That is capview_probe going through the ordinary system device enumerator, so the camera really does appear and really does answer GetStreamCaps with no administrator anywhere in the picture. There is no FilterData blob in that registration — IFilterMapper2::RegisterFilter was not involved — and with MERIT_DO_NOT_USE nothing should want to read one.

3.0 ships the machine-wide install anyway. What has been shown is that the filter enumerates; what has not been shown is that OBS, Discord and the browsers are happy with a registration that carries no FilterData, and a camera that quietly fails to appear for everyone is not a thing to find out after release. It is written down here as the first candidate for 3.1.

One piece of it did ship, because it is a correctness fix rather than a change of behaviour: RegisteredPath() now reads the per-user hive before the machine-wide one, in the order COM itself uses. If the two ever disagree, the per-user entry names the DLL that would really be loaded, and CapView reporting the other one would send someone hunting for an evening.

The 2.x registration is removed automatically

CapView 3.0 uses a different CLSID from the Media Foundation source, so installing it does not touch the old registration and the old camera would otherwise go on appearing in device lists, pointing at a DLL that no longer exists. RemoveLegacyRegistration() runs from both DllRegisterServer and DllUnregisterServer and deletes the 2.x class along with its entry under Windows Media Foundation\Platform\VirtualCameras.

Anyone upgrading from 2.x has to install the camera again. There is no way around it: the old filter and the new one are different COM classes.

The DLL is inside the executable

The filter has to be a separate DLL because DirectShow loads it into the consuming program. It is not a separate download: it travels inside CapView.exe as an RT_RCDATA resource (kFilterResourceId = 101) and is written out when the camera is installed.

A release is one file, and the executable can only ever install a filter 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, 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. 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.

Under DirectShow this matters less than it did — there is no long-lived service holding the file, so a program that closes releases it — but a consumer that stays open for days is ordinary, and the mechanism costs nothing.

CleanUpOldSources() sweeps every file matching the pattern except the one this build uses. Anything still mapped 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.

The shared memory

Three kinds of object, all under Local\:

Local\CapViewVCamControl        one control block
Local\CapViewVCamFrames-<gen>   the pictures, named by generation
Local\CapViewVCamWake-<0..7>    one auto-reset event per consumer slot

CapView creates all of them. Under Media Foundation it was the other way about, because only the service could create under Global\; now both halves are the same user and the producer can own its own objects.

Why the frames are in a section of their own, named by generation

Because their size follows the source, and the source changes. A mapped section cannot grow in place. So a format change makes a new section, and the control block's frameGeneration names the current one; consumers notice the number moved and remap. Releasing the old handle is safe while a consumer is still reading from it, because that consumer holds a handle of its own.

The alternative is sizing one section for the largest picture the camera will ever carry, and at 8K that is three hundred megabytes reserved to show a SNES.

Generations never start at zero. A fresh control block seeds from GetTickCount() | 1u, so a consumer that survived a CapView which did not clean up is never handed a generation it already has mapped.

The seqlock

struct alignas(64) SlotHeader {
  volatile uint32_t sequence;   // odd while being written, even when whole
  uint32_t width, height, strideY, pixel;
  uint32_t frameIndex;          // running count: new picture or repeat
  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. Three slots is enough that a reader normally never collides with the writer: one being written, one being read, one spare.

Normally. A torn read used to mean giving up until the next frame, and at large sizes the copy is long enough that contention stops being rare — measured at 8K, that turned into a black picture that never recovered: 0 fresh frames out of 94 served. ReadInto now retries up to three times, which is enough because the writer has moved to a different slot by then. The same test after the change: 239 fresh out of 269.

One wake event per consumer, not one shared

A single shared event cannot wake several readers exactly once — whoever waited first eats it. So there is one auto-reset event per consumer slot, and the producer signals the ones whose slots are in use.

Missing a wake is not a correctness problem: the delivery thread wakes on a timer regardless, and a reader that misses a signal simply serves the picture it already has. The event only saves it from spinning.

The consumer table

struct ConsumerSlot {
  volatile uint32_t inUse;
  uint32_t pid;
  wchar_t name[64];                     // the consuming executable
  volatile uint32_t width, height, pixel;
  volatile int64_t frameInterval100ns;  // 0 until a format is settled
  volatile uint32_t streaming, framesServed, framesFresh, lastSeenMs;
};

Eight slots. This is not a nicety — it is the shape of the design. There is one filter instance per consumer, each negotiating its own format, and two of them disagreeing is the normal case. Each instance claims a slot, writes its own answers into it, and heartbeats lastSeenMs.

Settings → Recording lists the table: program name, negotiated format, and whether it is merely connected or actually reading.

A quiet slot is not a dead one. A filter that has been instantiated but not yet started only touches its slot when its format is negotiated, and a consumer sitting in a preview dialog can be slow about that. So PruneConsumers() treats a slot quiet for more than three seconds as a candidate, and reclaims it only after confirming with OpenProcess/WaitForSingleObject that the process has actually exited.

Inside the filter

vcam_filter.cpp. One IBaseFilter with one output pin, which also implements IAMStreamConfig (the ranges), IKsPropertySet (PIN_CATEGORY_CAPTURE, which is how applications recognise a capture pin) and IAMFilterMiscFlags (AM_FILTER_MISC_FLAGS_IS_SOURCE).

Streaming starts in Pause, not Run

STDMETHODIMP Filter::Pause() {
  // A live source has nothing to hold still for. Streaming starts here rather
  // than in Run because a graph pauses first and expects the first sample to
  // be on its way before it ever calls Run.

A graph that pauses and waits for a sample that only arrives on Run deadlocks. Every live source does this.

The delivery thread paces itself

Pin::ThreadLoop() keeps a QueryPerformanceCounter deadline and adds one frame interval per sample. Being woken by a new picture is explicitly not a reason to deliver early:

// Woken by a new picture is not a reason to deliver early: the consumer
// asked for a rate and gets that rate. It only means the wait ends.
continue;

If it falls more than four frames behind — the machine slept, the consumer blocked — the deadline resets to now rather than delivering the backlog as fast as the loop can run.

This is the part 2.x got wrong in the other direction: the Media Foundation source served samples as fast as they were requested, measured at over 2000 a second for a 30 fps camera, and consumers showed nothing at all.

Scaling happens in the consumer's process

Compose() scales and letterboxes into whatever the consumer negotiated. That work happening inside Discord rather than inside CapView is the point: a 4:3 console in a 16:9 camera is pillarboxed rather than stretched, and the cost is paid by the program that asked for the odd shape, not by everything else reading the same camera.

The idle picture

src/vcam/vcam_idle.cpp

The camera is in every device list from the moment it is installed, and a camera that is always listed and shows black when idle is indistinguishable from a broken one. So when nothing is feeding it, it draws the CapView mark over a dark ground with CapView is not running underneath, and a smaller line saying to start CapView and turn the camera on.

The second line covers the other case honestly: CapView running with the camera switched off looks identical from here. The two cannot be told apart — a consumer that mapped the control section keeps that mapping alive after CapView exits, so the section still being there proves nothing about the process.

What separates black from the idle picture is producerAlive. A read that fails while the producer is alive is a torn slot or the moment before the first frame: that lasts a frame or two and gets black, because a sentence flashing up between two pictures would be worse than nothing. A read that fails with no producer is not going to succeed, and gets the explanation.

Drawn with GDI, in the consumer's process like everything else here:

  • GDI rather than a baked bitmap font, because the size is not known until the consumer has negotiated one. A font baked at one size looks like a font baked at one size at every other. ANTIALIASED_QUALITY and never ClearType — subpixel fringes become red and blue edges on the letters once they have been through a 4:2:0 chroma pass.
  • The mark is a resource in the DLL, palette-and-run-length packed by tools/make_vcam_icon.py: 213 colours in 1729 runs, 7.8 KiB instead of the 256 KiB the raw 256×256 image costs. It is paid for twice — once in the DLL, once again in CapView.exe, which carries the DLL — which is why it is packed at all. The decoder is twenty lines and the generator round-trips its own output before writing it.
  • Drawn once per shape and kept. Laying out text thirty times a second to say the same thing would be thirty times too many.
  • Both layouts. NV12 and P010, BT.709 limited range, converted from the BGRA canvas. P010 is only reachable by a consumer that negotiated it against an HDR source and then lost the producer, since an idle camera offers NV12 only — but that path exists, so it is tested.

Below about a hundred lines there is no room for a sentence and the subtitle is dropped; below that the mark is drawn alone. The pin does advertise down to 32×32, and a frame of one flat colour says nothing at all.

HDR

Off by default, and deliberately so: something that takes the ten bits without understanding them shows a wrong picture. When the source is HDR and the switch is on, P010 is offered first and NV12 immediately behind it, so a consumer that has never heard of an HDR webcam still finds something it understands.

The 2.x design needed a file in ProgramData to carry this switch across the session boundary before the shared section existed. That is gone: sourcePixel in the control block is the whole of it, and it doubles as the answer to whether there is anything to offer the ten bit form from — one field rather than two that can disagree.

SetSourceShape() is called every frame whether or not anybody is watching, and that ordering matters. Readback is only enabled once a consumer streams, so if the source shape were noted when a frame was pushed, it would still be zero at the moment a consumer negotiated — and every consumer would fall back to the idle 1280×720.

Testing it without OBS

capview_vcam_test.exe (built from src/tools/vcam_test.cpp, not shipped) is both halves of the contract in one program.

capview_vcam_test.exe --caps-only
capview_vcam_test.exe --size 1280x720 --fps 30 --seconds 5
capview_vcam_test.exe --produce --size 7680x4320 --fps 120

As a consumer it loads the DLL by path and calls DllGetClassObject directly, so it needs no registration and no administrator rights. It prints every capability with its ranges, optionally calls SetFormat, connects a minimal IPin + IMemInputPin sink — a DirectShow source needs nothing else on the other end — runs the filter, and reports the rate that actually arrived and whether the picture was blank.

As a producer it publishes a moving pattern into the same shared section CapView publishes into, written straight against vcam_shared.h rather than against VirtualCamera. That is deliberate: an independent second implementation of the same contract, so where the two agree the contract is unambiguous. It also means the reading half can be exercised with no capture card, no installed camera, and no CapView running at all.

The 8K seqlock failure above was found this way and would not have been found any other way.

Against the real producer

The test tool standing in for CapView leaves one half untested: VirtualCamera itself, the code in CapView that owns the section and fills it from the capture card. A temporary per-user registration was enough to get past the "installed?" gate and run the real thing, with the PAL source live on the SA7160:

consumer asked for arrived picture
native 300 samples in 5.005 s (59.94 fps) luma 17..234, mean 143
1280x720 @ 30 150 samples in 5.000 s (30 fps) luma 17..233, mean 172
640x480 @ 15 61 samples in 4.067 s (15 fps) luma 16..233, mean 153

The luma ranges are the tell: the synthetic producer paints a 16..235 ramp, so values that stop just short of it are the capture card's picture rather than the test pattern. CapView's own log agreed from the other side — Leser da, Leser weg, and a published count climbing by about 61 a second.

Known behaviour

  • Not visible to packaged apps. See the trade at the top.
  • A consumer keeps the size it opened with. Changing console mid-session does not change what OBS or Discord are asking for. See A format is settled once — in OBS the setting is Resolution/FPS Type: Device Default.
  • Slightly choppy in Discord under 2.x. Whether the DirectShow path changes that is untested — it was Discord's own handling of virtual cameras rather than CapView's pacing, and the same source was smooth in OBS on the same machine at the same time.

Clone this wiki locally