首页 / 资讯中心 / 文章详情

纯用户态DirectShow虚拟摄像头实现原理与实战

纯用户态DirectShow虚拟摄像头实现原理与实战 ★ FEATURED ARTICLE
简介本资源是一个基于DirectShow框架实现的VCAM虚拟摄像头开发项目面向Windows平台C开发者及多媒体底层技术学习者解决无物理摄像头时视频流模拟、测试与集成需求适用于远程会议调试、AI视觉算法验证、直播推流测试等场景。压缩包共154个文件含38个头文件h定义接口与结构、33个源码文件cpp实现核心过滤器逻辑如VCamD.ax/VCam.ax虚拟设备驱动、amfilter.cpp滤镜基类、winutil.cpp系统交互模块以及obj、pdb、sln等编译与工程支撑文件整体大小71.33MB。已有2452人学习下载项目完整呈现了DirectShow过滤图构建、自定义源过滤器开发、系统级摄像头注册、VMR渲染优化及多线程视频帧生成等关键技术链路代码结构清晰模块职责分明是深入理解Windows多媒体驱动层开发的优质实践范例。1. 一个不依赖第三方驱动、纯用户态实现的虚拟摄像头它能让任何调用 DirectShow 的老程序比如十年前的视频会议软件、工业采集界面立刻“看到”你喂进去的任意图像流——不是靠 Hook不是靠注册表劫持而是正经注册成系统里一个合法的、可被枚举的 Video Capture Filter这项目不是给 OBS 或 Zoom 这类现代应用准备的恰恰相反它是为那些卡在 Windows XP/7 时代、只认 DirectShow 架构、连 Media Foundation 都不支持的老系统写的。某高校实验室用它把 FPGA 实时图像处理板卡的输出伪装成标准 USB 摄像头直接喂进一套 2008 年开发的机器视觉质检界面某工业设备商拿它把 H.264 解码后的帧序列变成“即插即用”的摄像头设备让客户不用改一行原有代码就能接入新视频源。核心价值就三点零驱动安装纯 .dll .reg、完全兼容 legacy DirectShow 枚举链路、支持 RGB24/YUY2/NV12 多格式动态切换。它不解决 WebRTC 推流、不搞 AI 美颜、不碰 Vulkan 渲染——它只干一件事让ICreateDevEnum::CreateClassEnumerator(CLSID_VideoInputDeviceCategory)能稳稳地列出你这个“摄像头”且IAMStreamConfig::SetFormat后IMemInputPin::Receive真的能收到你 push 进去的每一帧。如果你正在被“老系统无法接入新视频源”卡住或者需要绕过 Windows 10/11 对传统摄像头驱动的签名限制这个 VCAM 就是你的后悔药。2. 架构与模块拆解从 COM 对象注册到帧数据泵送的完整链路2.1 整体分层结构Filter、Pin、Allocator 三者如何咬合整个 VCAM 的核心是一个标准 DirectShow Filter遵循 Microsoft 官方文档中对IBaseFilter的实现规范。它不继承CBaseFilter避免 ATL 依赖而是手写IUnknown、IPersistPropertyBag、ISpecifyPropertyPages等接口确保最小化运行时依赖。整个结构分为三层Filter 层负责生命周期管理、状态控制Stop/Pause/Run、枚举 PinEnumPins。关键点在于GetClassID返回CLSID_VCAMFilter且QueryFilterInfo中achName必须设为VCAM Virtual Camera这是系统设备管理器显示名称的来源。Output Pin 层继承自CBaseOutputPin此处使用 DShow baseclasses但仅链接静态库strmiids.lib和winmm.lib不依赖quartz.dll动态加载。它暴露IAMStreamConfig接口用于协商视频格式并通过IMemAllocator分配内存块。Frame Pump 层独立线程m_hThread循环调用m_pAllocator-GetBuffer(...)获取空闲缓冲区填充图像数据后调用Deliver()推送至下游。该线程受m_evRun事件控制Run()时置位Stop()时复位避免线程残留。提示所有 COM 接口方法必须严格遵循STDMETHODIMP宏定义返回S_OK/E_FAIL/VFW_S_NO_MORE_ITEMS等标准 HRESULT否则 Filter Manager 在枚举时会静默跳过该设备。2.2 关键 COM 接口实现逻辑详解VCAMFilter类实现了IBaseFilter的核心方法。我们重点看EnumPins和FindPinSTDMETHODIMP VCAMFilter::EnumPins(IEnumPins **ppEnum) { if (!ppEnum) return E_POINTER; *ppEnum NULL; // 创建枚举器对象内部持有 m_pOutputPin 引用 CEnumPins* pEnum new CEnumPins(m_pOutputPin); if (!pEnum) return E_OUTOFMEMORY; HRESULT hr pEnum-QueryInterface(__uuidof(IEnumPins), (void**)ppEnum); pEnum-Release(); // IEnumPins 构造时 AddRefQueryInterface 后 Release 一次 return hr; } STDMETHODIMP VCAMFilter::FindPin(LPCWSTR Id, IPin **ppPin) { if (!ppPin || !Id) return E_POINTER; *ppPin NULL; // DirectShow 要求 Output Pin 名为 Capture if (wcscmp(Id, LCapture) 0) { *ppPin m_pOutputPin; (*ppPin)-AddRef(); return S_OK; } return VFW_E_NOT_FOUND; }这段代码的关键在于FindPin必须识别Capture字符串这是 DirectShow 内部硬编码的 Output Pin 标准名否则IGraphBuilder::RenderStream会失败。而EnumPins返回的枚举器其Next()方法必须按顺序返回m_pOutputPin且Skip()和Reset()要正确维护内部索引。很多初学者在此处翻车——忘了AddRef()导致 Pin 被提前释放或Next()返回S_FALSE时机不对造成枚举中断。2.3 视频格式协商IAMStreamConfig 如何决定帧率与分辨率VCAMOutputPin实现了IAMStreamConfig接口这是下游 Filter如AVI Decompressor或Video Renderer用来设置视频参数的核心通道。GetNumberOfCapabilities和GetStreamCaps是重点STDMETHODIMP VCAMOutputPin::GetNumberOfCapabilities(int *piCount, int *piSize) { if (!piCount || !piSize) return E_POINTER; *piCount ARRAYSIZE(g_VideoFormats); // g_VideoFormats 是预定义的 AM_MEDIA_TYPE 数组 *piSize sizeof(VIDEO_STREAM_CONFIG_CAPS); return S_OK; } STDMETHODIMP VCAMOutputPin::GetStreamCaps(int iIndex, AM_MEDIA_TYPE **ppmt, BYTE *pSCC) { if (!ppmt || !pSCC) return E_POINTER; if (iIndex 0 || iIndex ARRAYSIZE(g_VideoFormats)) return E_INVALIDARG; // 分配并拷贝预设格式 *ppmt (AM_MEDIA_TYPE*)CoTaskMemAlloc(sizeof(AM_MEDIA_TYPE)); if (!*ppmt) return E_OUTOFMEMORY; CopyMediaType(*ppmt, g_VideoFormats[iIndex]); // 填充 VIDEO_STREAM_CONFIG_CAPS 结构体 VIDEO_STREAM_CONFIG_CAPS* pCaps (VIDEO_STREAM_CONFIG_CAPS*)pSCC; ZeroMemory(pCaps, sizeof(VIDEO_STREAM_CONFIG_CAPS)); pCaps-guid FORMAT_VideoInfo; // 必须是这个 GUID pCaps-VideoStandard AnalogVideo_None; pCaps-InputSize g_VideoFormats[iIndex].lSampleSize; // 帧大小 pCaps-MinCroppingSize.cx pCaps-MaxCroppingSize.cx pCaps-CropGranularityX g_VideoFormats[iIndex].pbFormat[24] | (g_VideoFormats[iIndex].pbFormat[25] 8); // width from VIDEOINFOHEADER // ... 其他字段同理 return S_OK; }这里g_VideoFormats是一个静态数组包含 RGB24640x48030fps、YUY21280x72015fps 等常用组合。GetStreamCaps不仅返回媒体类型还通过VIDEO_STREAM_CONFIG_CAPS告诉上游“我支持这些分辨率/帧率且裁剪粒度是 2x2”。如果此处返回的InputSize与实际Deliver()时IMediaSample::GetSize()不一致Video Renderer会直接报错VFW_E_SAMPLE_REJECTED。3. 编译与注册从源码到设备管理器里出现“VCAM Virtual Camera”3.1 构建环境配置要点VS2019Windows SDK 10.0项目使用纯 Win32 API DirectShow BaseClasses不依赖 ATL、MFC 或任何 C/CLI 扩展。编译前需确认三件事Windows SDK 版本必须为 10.0 或更高因wchar_t默认为原生类型且CoInitializeEx参数需COINIT_APARTMENTTHREADED。在 VS 项目属性 → General → Windows SDK Version 选10.0。字符集设置为Use Unicode Character Set项目属性 → General → Character Set。DirectShow 接口大量使用LPCWSTRANSI 会导致FindPin字符串比较失败。附加依赖项Linker → Input → Additional Dependencies 添加strmiids.lib winmm.lib ole32.lib oleaut32.lib。strmiids.lib提供CLSID_FilterGraph等 GUID 定义winmm.lib用于timeGetTime()实现帧率控制。注意不要勾选/clr或/ZW否则生成的 DLL 会被标记为托管模块无法被regsvr32注册。3.2 注册表项手工注入与 regsvr32 流程VCAM Filter 的注册不走DllRegisterServer为简化部署避免 COM 自注册逻辑而是提供install.reg文件。其核心内容如下Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Drivers32] vidc.yuy2vcam.dll vidc.rgbvcam.dll [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Class\{65E8773D-8F56-11D0-A3B9-00A0C9223196}] LowerFiltershex(7):76,00,63,00,61,00,6d,00,00,00,00,00 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Enum\Root\LEGACY_VCAM\0000] Servicevcam Legacydword:00000001 ConfigFlagsdword:00000000 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\vcam] Typedword:00000001 Startdword:00000003 ErrorControldword:00000001 ImagePathhex(2):25,00,53,00,79,00,73,00,74,00,65,00,6d,00,52,00,6f,00,6f,00,74,00,25,00,5c,00,53,00,79,00,73,00,74,00,65,00,6d,00,33,00,32,00,5c,00,76,00,63,00,61,00,6d,00,2e,00,64,00,6c,00,6c,00,00,00 DisplayNameVCAM Virtual Camera关键点有三Drivers32键值让系统知道vcam.dll可提供 YUY2/RGB 编解码器虽未实现编解码但此键是旧版枚举必需LowerFilters注入到视频类设备根键使 VCAM 出现在设备管理器的“图像设备”分类下LEGACY_VCAM条目模拟真实硬件 PnP 枚举路径ConfigFlags0表示启用。注册后需重启Plug and Play服务或执行devcon restart *需下载 DevCon 工具否则设备管理器不会刷新。3.3 验证注册是否成功的三步法注册完成后不能只看设备管理器有没有图标要分层验证COM 层验证运行oleview.exeWindows SDK 自带在File → View TypeLib中打开vcam.dll确认IVCAMFilter接口存在且CLSID_VCAMFilterGUID 正确Filter 枚举验证用 GraphEdit或开源替代品 GraphStudioNextGraph → Insert Filters展开Video Capture Sources应能看到VCAM Virtual CameraPin 连接验证将 VCAM 拖入图中右键Properties切换到Pin页确认CapturePin 状态为Connected: No且Media Types列出 RGB24/YUY2 等格式。若第 2 步看不到设备大概率是install.reg中LowerFilters路径错误或LEGACY_VCAM键名拼写有误若第 3 步无 Media Types则IAMStreamConfig::GetStreamCaps返回了非法AM_MEDIA_TYPE常见于pbFormat指针未正确指向VIDEOINFOHEADER结构。4. 常见问题排查那些让你对着黑屏抓狂的五个经典翻车现场4.1 现象设备管理器中显示“VCAM Virtual Camera”但 GraphEdit 里找不到或提示“Failed to create filter”原因CLSID_VCAMFilter在注册表中未正确映射到vcam.dll的DllGetClassObject入口。install.reg缺少HKEY_CLASSES_ROOT\CLSID\{xxx}\InprocServer32子键或ThreadingModel值未设为Both。解决手动补全注册表[HKEY_CLASSES_ROOT\CLSID\{A1234567-89AB-CDEF-0123-456789ABCDEF}\InprocServer32] C:\\path\\to\\vcam.dll ThreadingModelBoth其中{A1234567-...}是VCAMFilter.h中定义的CLSID_VCAMFilter值。4.2 现象GraphEdit 能看到设备双击 Properties 却报错“Cannot get property page”或 Pin 页空白原因VCAMFilter未实现ISpecifyPropertyPages接口或GetPages方法返回的CAUUID结构中cElems为 0。解决在VCAMFilter.cpp中添加STDMETHODIMP VCAMFilter::GetPages(CAUUID *pPages) { if (!pPages) return E_POINTER; pPages-cElems 1; pPages-pElems (GUID*)CoTaskMemAlloc(sizeof(GUID)); if (!pPages-pElems) return E_OUTOFMEMORY; *pPages-pElems CLSID_VCAMPropPage; // 假设已定义属性页 CLSID return S_OK; }即使不提供真实属性页也必须返回一个有效 GUID否则 GraphEdit 拒绝加载。4.3 现象连接成功但渲染器黑屏调试发现IMemInputPin::Receive从未被调用原因VCAMOutputPin::CheckMediaType方法返回S_FALSE或E_FAIL导致 Filter Graph 认为格式不匹配而拒绝连接。常见于AM_MEDIA_TYPE中majortype未设为MEDIATYPE_Video或subtype未设为MEDIASUBTYPE_RGB24等标准子类型。解决检查g_VideoFormats[i].majortype MEDIATYPE_Video;和g_VideoFormats[i].subtype MEDIASUBTYPE_RGB24;是否在初始化时正确赋值。用AM_MEDIA_TYPE结构体调试器观察majortype值是否为0x73646976ASCII vids 的小端序。4.4 现象画面撕裂、卡顿timeGetTime()计算的帧间隔忽大忽小原因帧泵线程未设置线程优先级被系统调度抢占。VCAMOutputPin::Run()启动的线程默认为THREAD_PRIORITY_NORMAL在多核 CPU 上易被迁移到高负载核心。解决在线程创建后立即提升优先级m_hThread CreateThread(NULL, 0, FramePumpThreadProc, this, 0, dwThreadId); if (m_hThread) { SetThreadPriority(m_hThread, THREAD_PRIORITY_HIGHEST); // 关键 SetThreadAffinityMask(m_hThread, 1); // 绑定到核心 0减少迁移 }4.5 现象Windows 10/11 上注册后设备管理器报错“驱动程序被阻止”状态码 0xE000022F原因微软从 Win10 1607 开始强制要求内核模式驱动必须经过 WHQL 签名但 VCAM 是用户态 Filter此错误实为误报。根源是注册表中HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\vcam的Type值被设为0x00000001内核驱动而用户态 DLL 应设为0x00000010Win32 服务。解决修改install.reg中Type值Typedword:00000010并删除Start和ErrorControl键用户态 DLL 不需要服务启动管理。5. 核心帧泵机制如何把 OpenCV Mat 或 FFmpeg AVFrame 喂进 VCAM 的内存管道5.1 内存模型解析IMemAllocator 如何分配与回收帧缓冲区VCAM 使用 DirectShow 标准的IMemAllocator分配方案而非自己malloc。VCAMOutputPin::Active()中调用m_pAllocator-SetProperties(allocProps)设置缓冲区参数ALLOCATOR_PROPERTIES allocProps {0}; allocProps.cBuffers 3; // 双缓冲不够3 缓冲防丢帧 allocProps.cbBuffer 640*480*3; // 最大帧大小RGB24 allocProps.cbAlign 1; // 内存对齐字节数 allocProps.cbPrefix 0; // 前缀保留字节如 RTP header m_pAllocator-SetProperties(allocProps, allocProps);SetProperties后IMemAllocator::GetBuffer返回的IMediaSample对象其GetPointer()返回的内存地址就是你填充图像数据的地方。关键约束必须在Deliver()调用前完成填充且不能在Deliver()返回后继续写该缓冲区——因为下游可能已开始读取。5.2 OpenCV Mat 到 IMediaSample 的零拷贝桥接推荐方案为避免cv::Mat::copyTo()的额外内存开销直接将 Mat 数据指针映射到IMediaSamplebool VCAMOutputPin::PushFrame(const cv::Mat mat) { if (mat.empty() || mat.type() ! CV_8UC3) return false; IMediaSample* pSample nullptr; HRESULT hr m_pAllocator-GetBuffer(pSample, NULL, NULL, 0); if (FAILED(hr)) return false; BYTE* pData nullptr; long lSize 0; pSample-GetPointer(pData); pSample-GetSize(lSize); // 直接 memcpyOpenCV Mat.data 是连续内存 memcpy(pData, mat.data, std::min((size_t)lSize, mat.total() * mat.elemSize())); // 设置时间戳单位100ns REFERENCE_TIME rtStart m_rtLastTime; REFERENCE_TIME rtEnd rtStart (10000000 / 30); // 30fps pSample-SetTime(rtStart, rtEnd); pSample-SetSyncPoint(TRUE); hr Deliver(pSample); pSample-Release(); m_rtLastTime rtEnd; return SUCCEEDED(hr); }此方案要求 Mat 是CV_8UC3RGB24且连续mat.isContinuous()为 true。若 Mat 来自cv::imread()或cv::VideoCapture默认满足若来自 ROI 操作需先mat mat.clone()确保连续性。5.3 FFmpeg AVFrame 到 VCAM 的 YUV420P 适配需格式转换FFmpeg 常输出AV_PIX_FMT_YUV420P而 VCAM 默认支持 YUY2。需在推送前做颜色空间转换// 初始化 SwsContext一次 SwsContext* sws_ctx sws_getContext( avframe-width, avframe-height, (AVPixelFormat)avframe-format, 1280, 720, AV_PIX_FMT_YUYV422, // 输出 YUY2 SWS_BILINEAR, NULL, NULL, NULL); // 转换每帧 uint8_t* dst_data[4]; int dst_linesize[4]; av_image_alloc(dst_data, dst_linesize, 1280, 720, AV_PIX_FMT_YUYV422, 1); sws_scale(sws_ctx, avframe-data, avframe-linesize, 0, avframe-height, dst_data, dst_linesize); // 推送 dst_data[0] IMediaSample* pSample; m_pAllocator-GetBuffer(pSample, NULL, NULL, 0); BYTE* pData; pSample-GetPointer(pData); memcpy(pData, dst_data[0], dst_linesize[0] * 720); // ... 后续 Deliver注意av_image_alloc分配的内存需在帧泵线程结束时av_freep(dst_data[0])否则内存泄漏。更优做法是预分配一块固定大小的 YUY2 缓冲区在VCAMOutputPin构造时new uint8_t[1280*720*2]复用避免频繁分配。6. 二次开发实战从“喂图”到“动态参数控制”的三步改造6.1 第一步暴露 SetResolution 接口支持运行时切换分辨率原始 VCAM 的分辨率是编译期固定的g_VideoFormats数组。要支持运行时切换需在VCAMFilter类中添加// VCAMFilter.h 新增 public: STDMETHODIMP SetResolution(LONG width, LONG height, LONG fps); // VCAMFilter.cpp 实现 STDMETHODIMP VCAMFilter::SetResolution(LONG width, LONG height, LONG fps) { // 1. 遍历 g_VideoFormats 查找匹配项 for (int i 0; i ARRAYSIZE(g_VideoFormats); i) { VIDEOINFOHEADER* pVIH (VIDEOINFOHEADER*)g_VideoFormats[i].pbFormat; if (pVIH-bmiHeader.biWidth width pVIH-bmiHeader.biHeight height (10000000 / pVIH-AvgTimePerFrame) fps) { // 2. 通知 Output Pin 切换格式 m_pOutputPin-SetCurrentFormat(g_VideoFormats[i]); return S_OK; } } return E_FAIL; }VCAMOutputPin::SetCurrentFormat需重写CheckMediaType和GetMediaType并触发IMemAllocator::Decommit()后重新Commit()以适配新缓冲区大小。此接口可通过ICustomControl自定义 COM 接口暴露或更简单地——在VCAMFilter中实现IDispatch供 VBScript/PowerShell 调用。6.2 第二步集成简单图像处理流水线灰度/镜像在PushFrame中插入处理逻辑但必须保证实时性。避免 OpenCV 的cv::cvtColor太重用查表法实现灰度// 预计算灰度查找表RGB24 - GRAY static BYTE g_Lut[256*3]; // R,G,B 各 256 个值 void InitGrayLUT() { for (int i 0; i 256; i) { g_Lut[i*3 0] (BYTE)(0.299*i); // R g_Lut[i*3 1] (BYTE)(0.587*i); // G g_Lut[i*3 2] (BYTE)(0.114*i); // B } } // 在 PushFrame 中 if (m_bEnableGrayscale) { BYTE* pDst pData; for (int i 0; i mat.rows * mat.cols; i) { BYTE r mat.data[i*3 2]; BYTE g mat.data[i*3 1]; BYTE b mat.data[i*3 0]; BYTE gray g_Lut[r*3 0] g_Lut[g*3 1] g_Lut[b*3 2]; pDst[i*3 0] pDst[i*3 1] pDst[i*3 2] gray; } }镜像更简单memcpy(pData, mat.data (mat.rows-1-y)*mat.step, mat.cols*3)循环逐行反向拷贝。这种 CPU 操作在 640x48030fps 下i5-8250U 实测占用 8% CPU安全。6.3 第三步添加帧率动态调节基于时间戳精度修正原始代码用timeGetTime()计算间隔但该函数精度仅 10-15ms30fps 实际抖动严重。改用QueryPerformanceCounter// VCAMOutputPin 成员变量 LARGE_INTEGER m_liFreq; LARGE_INTEGER m_liLastTick; // Active() 中初始化 QueryPerformanceFrequency(m_liFreq); QueryPerformanceCounter(m_liLastTick); // FramePumpThreadProc 中 LARGE_INTEGER liNow; QueryPerformanceCounter(liNow); REFERENCE_TIME rtDelta (liNow.QuadPart - m_liLastTick.QuadPart) * 10000000 / m_liFreq.QuadPart; m_liLastTick liNow; // 若 rtDelta 目标间隔Sleep 补偿 REFERENCE_TIME targetInterval 10000000 / m_nTargetFPS; if (rtDelta targetInterval - 10000) { // 10us 容差 Sleep((DWORD)((targetInterval - rtDelta) / 10000)); }这样帧率误差可控制在 ±0.1fps 内。我一般会强制在VCAMOutputPin::SetCurrentFormat时重置m_liLastTick避免格式切换后首帧时间戳错乱。从那以后我每次改 VCAM 的帧泵逻辑都强制走一遍QueryPerformanceCounter校准 Sleep补偿的闭环再用Windows Performance Analyzer抓取Thread Time事件验证抖动。这套组合拳下来连最挑剔的工业相机采集软件都认它是个“真硬件”。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站