-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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 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.
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. -
DllRegisterServerstops the frame server (StopFrameServer()invcam_dll.cpp) —ControlService(SERVICE_CONTROL_STOP)and then up to four seconds of polling.regsvr32already 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.
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.
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.
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.
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.
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.
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.
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.
| 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.
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.
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.