DirectShow 虚拟摄像头:.ax 源滤镜与 FillBuffer 实现

发布时间:2026/10/24 6:00:55
DirectShow 虚拟摄像头:.ax 源滤镜与 FillBuffer 实现

简介:DirectShow 实现的 VCAM 虚拟摄像头工程项目,面向 Windows 多媒体开发者,适合需要在不依赖物理摄像头的情况下提供连续视频流的测试、演示与软件集成场景,也可作为学习 DirectShow 滤镜体系的实战样本。压缩包共 154 个文件,约 71.33MB,包含 38 个 .h 头文件与 33 个 .cpp 源文件,辅以 .ax 滤镜模块、.sln/.vcxproj 工程文件、.lib 库及 .obj/.pdb 等构建中间产物,便于直接编译排查。内容围绕源过滤器创建、数据生成、设备注册、过滤图构建与多线程传输展开,并涉及 Sample Grabber 抓帧、VMR 渲染及性能优化等要点,可帮助读者理解虚拟摄像头从注册到被应用枚举调用的完整链路。项目覆盖从滤镜设计、媒体样本流转到设备注册与兼容性验证的关键环节,能作为虚拟摄像头开发、联调与排错思路的参考。已有 2450 人学习,适合具备一定 C++ 与 Windows 开发基础、希望掌握 VCAM 实现细节的读者对照工程结构学习。

1. 一个 .ax 文件就多出个摄像头:vcam 虚拟摄像头的定位与适用场景

上个月帮人排查一个录屏工具的问题,现象很怪:程序里画面渲染得好好的,一进会议软件就找不到可选的视频源,设备列表里只有物理摄像头。后来换了个思路——不跟会议软件较劲,直接往系统里注册一个视频输入设备。这就是 vcam 虚拟摄像头的思路:用 DirectShow 写一个源滤镜,编译成 .ax 动态库,挂着「视频输入设备」的类别注册进系统,任何按 DirectShow 枚举摄像头的软件打开下拉框,就会多出一个设备名,选中它,你灌进去的画面就成了「摄像头画面」。

它适合三类人:手上有一路自己渲染的画面(虚拟形象、白板、远程流、离线视频),想让它出现在会议软件里;做过音视频链路,但没碰过捕获源滤镜,想搞清楚 CSourceStream 那一套到底怎么转的;以及需要在 CI 里跑自动化视觉测试,想要一个稳定不变的假摄像头。整个工程的核心产物只有一个 DLL 加一段注册代码,代码量不大,翻车的点却几乎全压在格式协商和 FillBuffer 上。

2. 架构拆解:源滤镜、输出 Pin 与共享内存通道的三层分工

先把边界划清楚,后面读代码才不晕。这个工程里没有「摄像头驱动」这种概念,它就是一个用户态的 COM 组件,由使用它的应用程序进程加载(inproc),在同一个进程里被实例化、被连接、被拉数据。真正提供像素数据的生产者可以是另一个进程,也可以是同一个进程里的另一个模块,两者之间靠一块命名共享内存加一个命名事件通信。之所以拆成 producer / filter 两层,是因为虚拟摄像头最常见的用法就是「我已经有了画面,只是缺一个出口」,把画面生成和 DirectShow 适配分开,改画面的时候不用重新注册滤镜。

2.1 从应用枚举设备到 FillBuffer 被调用,中间发生了什么

应用侧的动作顺序大致是这样:先 CoCreateInstance(CLSID_FilterMapper2) 拿枚举器,按 CLSID_VideoInputDeviceCategory 这个类别枚举设备 moniker,读 moniker 上的 FriendlyName 填进下拉框;用户选中之后,应用拿 moniker 绑定的 CLSID 去 CoCreateInstance,把滤镜实例创建出来,AddFilter 加进图里;再调 IGraphBuilder::Render 或者手动 Connect,让滤镜的输出 Pin 连到下游。

连接阶段会走一遍媒体类型协商:上游调输出 Pin 的 EnumMediaTypes,把候选类型交出来;下游挑一个它认识的,调 ReceiveConnection;Pin 侧在 CheckMediaType 里做最后把关,通过了才真正建立连接,接着调 DecideBufferSize 定分配器的缓冲数目和每块大小。连好之后图进入运行状态,DirectShow 会给 CSourceStream 起一个工作线程,线程循环调用 FillBuffer,每调一次就填一块样本,填完把样本往下游推。整条链路上真正由我们控制的就三个函数:给类型、定缓冲、填数据,剩下的 BaseClasses 都替我们做了。

这里有个容易忽略的点:设备的 merit 值。视频输入设备类别的 merit 一般不设成高优先级,因为真实摄像头也是在这个类别里,抢了 merit 会出现「应用默认选中虚拟摄像头」的怪现象。常见做法是 MERIT_DO_NOT_USE,让应用自己去选,行为最可预期。

2.2 目录结构:一个能编译能跑通的 VCAM 工程长什么样

我把这套东西整理成了下面这个骨架,能跑通的最小集合都在里面。生产者单独放 tools 目录、不跟滤镜工程混编,这是血泪经验——混在一起最容易出现「滤镜里有静态库依赖,注册到系统后加载失败」这种玄学问题。

vcam/ ├─ src/ │ ├─ vcam.cpp // DllMain、类厂、DllRegisterServer / DllUnregisterServer │ ├─ vcamfilter.h/.cpp // CVCamFilter : CSource,设备壳,持有输出 Pin │ ├─ vcamstream.h/.cpp // CVCamStream : CSourceStream,输出 Pin,主力 │ ├─ vcamprops.h/.cpp // 属性页:分辨率、帧率、像素格式 │ └─ framesource.h/.cpp // 共享内存读端,含超时与兜底帧 ├─ lib/ │ └─ strmbase.lib // BaseClasses 静态库,x86 与 x64 各编一份 ├─ tools/ │ ├─ testpattern/ // 测试图案生产者,不依赖任何采集设备就能验证链路 │ └─ graphtest/ // 最小宿主:枚举设备 + 手工建图 + 统计帧率 ├─ build/ │ └─ vcam.def // 导出 DllRegisterServer / DllUnregisterServer └─ package/ └─ install.bat / uninstall.bat

src 里的四个文件是核心。vcamfilter 只负责「我是个设备、我有一个 Pin」,逻辑薄得像张纸;vcamstream 才是重头戏,GetMediaType、DecideBufferSize、FillBuffer 三个重载全在这里;framesource 把共享内存的打开、超时、兜底封装成一个 Open / WaitFrame / ReadFrame 的小接口,好处是将来换成管道、换成 socket 甚至换成直接调你的渲染器,只改这一个文件。

tools 目录看着像可选项,实际是救命用的。testpattern 会画一个带帧号和时间戳的渐变条,肉眼一看就知道画面在不在刷新、刷新率是不是掉了;graphtest 用代码建图并统计每秒收到的样本数,能排除「应用本身有问题」这个变量。很多人排查到最后发现是自己宿主写错了,白白怀疑滤镜。

2.3 帧数据通道:共享内存加命名事件的双缓冲

生产者给滤镜送帧,最省事的方式就是共享内存。理由很直接:视频帧动辄几 MB,走管道或者 socket 要来回拷,30 帧 1080p 的拷贝开销足够把 CPU 吃满一截,而共享内存只有映射和一次 memcpy。同步用命名事件,生产者填完一帧 SetEvent,滤镜侧 WaitForSingleObject 等信号,超时就走兜底。

共享内存的开头固定放一个头结构,描述这一帧的宽高、行跨度和像素格式。行跨度这个字段千万别省,因为不同的像素格式每行字节数不一定等于「宽 × 每像素字节」,对齐规则不一样,少了它下游就会读出斜的图。

// framesource.h —— 共享内存帧头,生产者与滤镜共用同一份定义 #pragma pack(push, 1) struct FrameHeader { LONG width; // 像素宽,必须偶数(YUY2 每两个像素占 4 字节) LONG height; // 像素高 LONG stride; // 每行字节数,可能大于 width * bytesPerPixel DWORD format; // 1=BGR32, 2=NV12, 3=YUY2 LONGLONG ptsHundredNs; // 显示时间戳,单位 100ns,跟 DirectShow 参考时钟一致 }; #pragma pack(pop) static const size_t kHeaderSize = sizeof(FrameHeader); static const size_t kMaxImage = 1920 * 1088 * 4; // 留足 1080p BGR32 的余量

结构体用#pragma pack(1)压紧,是为了让两边对字段偏移的理解完全一致;生产者和滤镜是两个独立编译单元,只要有一个人在结构体里插了个字段又没对齐,读到的 width 就会变成一个天文数字,然后 FillBuffer 直接越界崩溃,调试起来非常难受。ptsHundredNs用 100 纳秒为单位,是因为 DirectShow 的 REFERENCE_TIME 就是这个单位,生产者直接算好,滤镜侧就不用再做换算,少一次出错的机会。

映射本身没什么花活,创建端用 CreateFileMappingW(INVALID_HANDLE_VALUE, ...) 建内存映射,打开端用 OpenFileMappingW。命名要不要加Global\前缀得想清楚:加了跨会话可见,但需要相应权限,普通用户起生产者的时候容易失败;不加就只在当前登录会话有效,对绝大多数桌面场景够用,也省掉一堆权限麻烦。

3. 核心代码:FillBuffer、格式协商与 IAMStreamConfig 的落地写法

这一章直接上代码。CVCamStream 继承 CSourceStream,同时实现 IAMStreamConfig 和 IKsPropertySet 两个接口。前者是应用切分辨率、查能力的入口,后者用来告诉应用「我这个 Pin 属于捕获类别」,少了 IKsPropertySet,一部分软件枚举完设备后会认为它不可用,直接从列表里过滤掉。

// vcamstream.h —— 输出 Pin 的类声明,接口一个都不能少 class CVCamStream : public CSourceStream, public IAMStreamConfig, public IKsPropertySet { public: CVCamStream(HRESULT* phr, CSource* pParent, LPCWSTR pPinName); ~CVCamStream(); // CSourceStream 的三个必答项 HRESULT FillBuffer(IMediaSample* pSample) override; HRESULT DecideBufferSize(IMemAllocator* pAlloc, ALLOCATOR_PROPERTIES* pProps) override; HRESULT GetMediaType(int iPosition, CMediaType* pmt) override; HRESULT CheckMediaType(const CMediaType* pmt) override; HRESULT OnThreadCreate() override; // 工作线程起来时打开共享内存 HRESULT OnThreadDestroy() override; // 线程退出时释放 // IAMStreamConfig STDMETHODIMP SetFormat(AM_MEDIA_TYPE* pmt) override; STDMETHODIMP GetFormat(AM_MEDIA_TYPE** ppmt) override; STDMETHODIMP GetNumberOfCapabilities(int* piCount, int* piSize) override; STDMETHODIMP GetStreamCaps(int iIndex, AM_MEDIA_TYPE** ppmt, BYTE* pSCC) override; // IKsPropertySet:至少要让 PIN_CATEGORY_CAPTURE 查得到 STDMETHODIMP Set(REFGUID, REFGUID, LPVOID, DWORD, LPVOID, DWORD) override { return E_NOTIMPL; } STDMETHODIMP Get(REFGUID guidPropSet, DWORD dwId, LPVOID, DWORD, LPVOID pInstanceData, DWORD cbInstanceData, LPVOID pPropData, DWORD cbPropData, DWORD* pcbReturned) override; STDMETHODIMP QuerySupported(REFGUID, DWORD, DWORD* pTypeSupport) override; private: CFrameSource m_source; // 共享内存读端 CMediaType m_mt; // 当前协商出来的类型 REFERENCE_TIME m_rtFrame; // 已经输出多少帧 REFERENCE_TIME m_rtPerFrame; // 每帧时长,100ns BYTE* m_pLastFrame; // 上一帧副本,生产者掉线时兜底用 };

类声明里最值得说的是m_pLastFrame。很多人写虚拟摄像头,生产者一断,FillBuffer 就返回失败或者原地等,结果下游软件画面直接黑掉甚至断流。加一份上一帧副本,超时就复制旧帧、时间戳照常递增,表现是画面卡住而不是流崩掉——对使用者来说,这两种体验差得远。

3.1 GetMediaType 与 DecideBufferSize:类型和缓冲要一次说清

这两个函数是连接阶段的门面。GetMediaType 的返回值有三个含义:S_OK 表示这个位置有类型,S_FALSE 表示枚举结束,其它是错误。新人最容易在这里翻车的是「最后一个类型之后返回 E_INVALIDARG」——应用会认为滤镜有故障,直接放弃连接,表现是设备能看到但点了没反应。

// vcamstream.cpp —— 按索引吐出一个候选媒体类型 HRESULT CVCamStream::GetMediaType(int iPosition, CMediaType* pmt) { CAutoLock cAutoLock(m_pFilter->pStateLock()); if (iPosition < 0) return E_INVALIDARG; if (iPosition >= (int)g_Formats.size()) return S_FALSE; // 枚举结束,必须是 S_FALSE const FormatDesc& fd = g_Formats[iPosition]; pmt->SetType(&MEDIATYPE_Video); pmt->SetSubtype(fd.subtype); // MEDIASUBTYPE_YUY2 / NV12 / RGB24 pmt->SetFormatType(&FORMAT_VideoInfo); VIDEOINFOHEADER* pVIH = (VIDEOINFOHEADER*)pmt->AllocFormatBuffer(sizeof(VIDEOINFOHEADER)); if (!pVIH) return E_OUTOFMEMORY; ZeroMemory(pVIH, sizeof(VIDEOINFOHEADER)); pVIH->AvgTimePerFrame = UNITS / fd.fps; // UNITS = 10000000 pVIH->bmiHeader.biSize = sizeof(BITMAPINFOHEADER); pVIH->bmiHeader.biWidth = fd.width; pVIH->bmiHeader.biHeight = fd.height; // 正数表示 bottom-up pVIH->bmiHeader.biPlanes = 1; pVIH->bmiHeader.biBitCount = (WORD)fd.bitCount; pVIH->bmiHeader.biCompression = fd.biCompression; pVIH->bmiHeader.biSizeImage = fd.stride * fd.height; pmt->SetSampleSize(pVIH->bmiHeader.biSizeImage); return S_OK; }

AvgTimePerFrame是帧率的倒数,单位 100ns,30fps 就填 333333。有的应用(尤其是老版本)会拿这个值自己算预期帧率,填成 0 或者不填,表现是画面慢放或者快进。biHeight给正数表示图像自下而上存,很多软件默认按这个方向理解,你要是按自上而下填数据又不改符号,出来的画面是上下颠倒的——这个坑我在别的项目里踩过一次,调了半个下午才发现是符号问题。

DecideBufferSize 里要做的事很单调,但一定要校验分配器的实际返回值。分配器有可能不给你要的数量,如果它砍到 1 块缓冲而 FillBuffer 里默认有两块在交替,就会出现读到上一轮还在写的数据。

// vcamstream.cpp —— 定分配器参数,宁可失败也别凑合 HRESULT CVCamStream::DecideBufferSize(IMemAllocator* pAlloc, ALLOCATOR_PROPERTIES* pProps) { CAutoLock cAutoLock(m_pFilter->pStateLock()); VIDEOINFOHEADER* pVIH = (VIDEOINFOHEADER*)m_mt.pbFormat; if (!pVIH) return E_UNEXPECTED; const long cbImage = pVIH->bmiHeader.biSizeImage; pProps->cbBuffer = cbImage; pProps->cBuffers = 2; // 双缓冲,一块在填一块在下游 pProps->cbAlign = 1; pProps->cbPrefix = 0; ALLOCATOR_PROPERTIES actual = {}; HRESULT hr = pAlloc->SetProperties(pProps, &actual); if (FAILED(hr)) return hr; if (actual.cbBuffer < cbImage || actual.cBuffers < 2) return E_FAIL; // 打了折扣,后面 FillBuffer 会写越界 return S_OK; }

cbBuffer直接取 biSizeImage,也就是 stride × height,不要用宽 × 高 × 字节数现算,YUY2 和 NV12 的行对齐规则不一样,现算的结果往往比实际需要的少几十字节,越界写入一字节都可能踩坏堆。cBuffers = 2是个经验值,给 1 块容易在慢机器上出现撕裂,给多了内存占用上去而收益不明显。

3.2 IAMStreamConfig:能力枚举、查询、设置三件套

应用侧要看「这个摄像头支持哪些分辨率」,走的就是 GetNumberOfCapabilities + GetStreamCaps。前者返回能力项个数和每项结构体大小,后者按索引填一个 AM_MEDIA_TYPE 加一块 VIDEO_STREAM_CONFIG_CAPS。这块结构体很多人随手 ZeroMemory 完事,结果应用读到的 MaxFrameInterval 是 0,判定设备不可用。

// vcamstream.cpp —— 把能力项填完整,别只填宽高 STDMETHODIMP CVCamStream::GetStreamCaps(int iIndex, AM_MEDIA_TYPE** ppmt, BYTE* pSCC) { if (!ppmt) return E_POINTER; CMediaType mt; HRESULT hr = GetMediaType(iIndex, &mt); if (hr != S_OK) return S_FALSE; // 越界按「枚举结束」处理 *ppmt = CreateMediaType(&mt); if (!*ppmt) return E_OUTOFMEMORY; if (pSCC) { VIDEO_STREAM_CONFIG_CAPS* pCaps = (VIDEO_STREAM_CONFIG_CAPS*)pSCC; ZeroMemory(pCaps, sizeof(*pCaps)); const FormatDesc& fd = g_Formats[iIndex]; pCaps->guid = FORMAT_VideoInfo; pCaps->VideoStandard = AnalogVideo_None; pCaps->InputSize.cx = fd.width; pCaps->InputSize.cy = fd.height; pCaps->MinCroppingSize = pCaps->InputSize; pCaps->MaxCroppingSize = pCaps->InputSize; pCaps->CropGranularityX = 1; pCaps->CropGranularityY = 1; pCaps->MinOutputSize = pCaps->InputSize; pCaps->MaxOutputSize = pCaps->InputSize; pCaps->MinFrameInterval = UNITS / fd.fps; // 最快 pCaps->MaxFrameInterval = UNITS / fd.fps; // 最慢,固定帧率就填成一样 pCaps->MinBitsPerSecond = (LONG)fd.stride * fd.height * fd.fps; pCaps->MaxBitsPerSecond = pCaps->MinBitsPerSecond; } return S_OK; }

MinFrameInterval和MaxFrameInterval填成一样的,就等于告诉应用「我只支持这一个帧率」,简单粗暴但兼容性最好。如果你想支持动态帧率,把范围放开,同时在 FillBuffer 里按帧头里的 pts 做节拍控制,别用固定 Sleep。

SetFormat 是应用在下拉框里切分辨率时调的。这里最忌讳的是「不做校验直接接受」——应用可能传一个 4K 的类型过来,你的共享内存只开了 1080p 大小,第一帧就炸。稳妥的写法是拿传进来的类型跟候选表逐项比,对不上就返回 VFW_E_INVALIDMEDIATYPE。

// vcamstream.cpp —— 切换格式时必须做白名单校验 STDMETHODIMP CVCamStream::SetFormat(AM_MEDIA_TYPE* pmt) { CAutoLock cAutoLock(m_pFilter->pStateLock()); if (!pmt) { // 传 NULL 是「恢复默认」的约定 m_mt = m_mtDefault; return S_OK; } if (!IsInWhitelist(pmt)) // 逐项比对 subtype、宽高、bitCount return VFW_E_INVALIDMEDIATYPE; m_mt = *pmt; VIDEOINFOHEADER* pVIH = (VIDEOINFOHEADER*)m_mt.pbFormat; m_rtPerFrame = (pVIH->AvgTimePerFrame > 0) ? pVIH->AvgTimePerFrame : UNITS / 30; // 应用没给帧率就按 30 兜底 return S_OK; }

白名单比对的顺序也有讲究:先比 subtype,再比宽高,最后比 bitCount。subtype 不一致直接否决,是最快的一条路径;宽高不一致但 subtype 相同的情况下,如果你实现了缩放,也可以放行,代价是 FillBuffer 里多一次缩放开销,这个取舍看你场景。

3.3 FillBuffer:帧率节拍、时间戳与格式转换

FillBuffer 是整条链路里唯一会被高频调用的函数,工作线程一秒钟调三十次,每次都要在几毫秒内返回,所以里面不能有阻塞式的等待,不能有锁竞争,更不能抛异常。

// vcamstream.cpp —— 每调一次填一块样本 HRESULT CVCamStream::FillBuffer(IMediaSample* pSample) { BYTE* pData = nullptr; if (FAILED(pSample->GetPointer(&pData)) || !pData) return E_FAIL; const long cbSize = pSample->GetSize(); // 等生产者推帧,最多等 30ms;等不到就用上一帧,保证流不断 bool gotNew = m_source.WaitFrame(30); if (gotNew) { m_source.CopyFrameTo(pData, cbSize, m_mt); // 内部处理格式转换 } else if (m_pLastFrame) { memcpy(pData, m_pLastFrame, min((size_t)cbSize, (size_t)m_cbLastFrame)); } else { memset(pData, 0x80, cbSize); // 起始阶段给灰底,别给黑 } // 时间戳必须单调递增,断层会让部分应用判定流异常 REFERENCE_TIME rtStart = m_rtFrame * m_rtPerFrame; REFERENCE_TIME rtEnd = rtStart + m_rtPerFrame; ++m_rtFrame; pSample->SetTime(&rtStart, &rtEnd); pSample->SetSyncPoint(TRUE); return S_OK; }

几个细节值得展开。等待超时给 30ms 是按 30fps 的帧间隔估的,你要是做 60fps 就把超时压到 15ms 左右,否则一帧的生产者抖动会连累后面好几帧的节拍。时间戳用m_rtFrame * m_rtPerFrame递增,而不是取系统当前时间,是为了让帧间隔绝对均匀——某些做录制对齐的软件会拿时间戳算音画同步,抖动大会导致音画慢慢错开。

SetSyncPoint(TRUE)表示这是关键帧,对纯视频预览影响不大,但接到编码链路里就有意义了。至于没帧的时候填 0x80 灰底而不是 0x00 黑底,是个小技巧:黑色会被一部分软件误判成「设备未就绪」而弹提示,灰色不会。

格式转换尽量在 CopyFrameTo 里一次做完,不要在 Filter 里做「先转 RGB 再转 YUY2」这种两段式。BGR32 到 YUY2 的换算按 BT.601 系数走,每两个像素共享一组 UV:

// framesource.cpp —— BGR32 转 YUY2,按 BT.601 定点运算 static inline BYTE Clamp(int v) { return (BYTE)(v < 0 ? 0 : (v > 255 ? 255 : v)); } static void BGR32ToYUY2(const BYTE* src, int srcStride, BYTE* dst, int dstStride, int w, int h) { for (int y = 0; y < h; ++y) { const BYTE* s = src + (size_t)y * srcStride; BYTE* d = dst + (size_t)y * dstStride; for (int x = 0; x < w; x += 2) { int b0 = s[0], g0 = s[1], r0 = s[2]; int b1 = s[4], g1 = s[5], r1 = s[6]; s += 8; // 两个像素,每个 4 字节 int y0 = ( 66 * r0 + 129 * g0 + 25 * b0 + 128) >> 8) + 16; int y1 = ( 66 * r1 + 129 * g1 + 25 * b1 + 128) >> 8) + 16; int u = ((-38 * r0 - 74 * g0 + 112 * b0 + 128) >> 8) + 128; int v = ((112 * r0 - 94 * g0 - 18 * b0 + 128) >> 8) + 128; d[0] = Clamp(y0); d[1] = Clamp(u); d[2] = Clamp(y1); d[3] = Clamp(v); d += 4; } } }

定点系数是标准的那一套,右移 8 位相当于除以 256,比浮点快很多,画质差距肉眼看不出来。注意s += 8一次跳两个像素,说明源数据必须按 BGR32 紧密排列,如果生产者的 stride 不等于 width × 4,这个函数就得改成按行首地址取,别直接用自增。

4. 编译与注册:把 DLL 变成「设备管理器里的一个摄像头」

代码写对了,能不能被系统认出来,全看编译和注册这两步。这两步出问题的概率比写 FillBuffer 高得多,而且症状都挺吓人:设备列表里看不到、看得到打不开、打开了黑屏。

4.1 依赖与编译:BaseClasses 与两份产物

DirectShow 的源滤镜几乎都建立在 BaseClasses 之上,CSource、CSourceStream、CMediaType、CAMThread 这些类能省掉至少两千行样板代码。但较新的 Windows SDK 里已经不带 BaseClasses 源码了,得自己找一份旧版或者独立包,编译成静态库再链接。注意这个库要跟你的工程用同一套运行库配置,MD 对 MD、MT 对 MT,混了会出现一堆链接期符号重定义。

第二件事是位数。这一点被无数人栽过:64 位系统上,64 位的应用只能加载 64 位的滤镜,32 位的应用只能加载 32 位的滤镜,两者互不相通。而现在相当一部分会议软件、直播软件还是 32 位进程。结论很直接——必须编两份,x86 一份、x64 一份,分别用对应位数的 regsvr32 注册。

:: build.bat —— 一次产出两份,别偷懒只编 64 位 @echo off set SRC=src\vcam.cpp src\vcamfilter.cpp src\vcamstream.cpp src\vcamprops.cpp src\framesource.cpp cl /nologo /EHsc /LD /MD /D_WIN32_WINNT=0x0601 /DWIN32 ^ /I"%DXSDK_INC%" /I"src" %SRC% ^ /link /DEF:build\vcam.def /OUT:bin\vcam_x86.ax ^ /LIBPATH:lib\x86 strmbase.lib ole32.lib oleaut32.lib uuid.lib ^ /MACHINE:X86 cl /nologo /EHsc /LD /MD /D_WIN32_WINNT=0x0601 /DWIN32 ^ /I"%DXSDK_INC%" /I"src" %SRC% ^ /link /DEF:build\vcam.def /OUT:bin\vcam_x64.ax ^ /LIBPATH:lib\x64 strmbase.lib ole32.lib oleaut32.lib uuid.lib ^ /MACHINE:X64

/LD决定产物是 DLL,扩展名用 .ax 是 DirectShow 滤镜的惯例(本质还是 DLL,regsvr32 一样认)。/DEF里必须导出 DllRegisterServer 和 DllUnregisterServer 两个符号,否则 regsvr32 会报「找不到入口点」。/MD跟 BaseClasses 的编译选项对齐,这一条错了会在链接期报一堆已经定义的符号,看着像代码冲突,其实是运行库重复。

4.2 注册脚本与注册表键位

注册做两件事:一是把 COM 服务器信息写进 HKCR\CLSID{你的GUID}\InprocServer32,让系统知道这个 CLSID 对应哪个 DLL、线程模型是什么;二是把这个 CLSID 挂进「视频输入设备」类别,让枚举器能找到它。第二件事用 IFilterMapper2 做最省心,它会自动处理好 moniker 和 DevicePath。

// vcam.cpp —— DllRegisterServer 的实现主干 STDAPI DllRegisterServer() { HRESULT hr = RegisterComServer(); // 写 InprocServer32,ThreadingModel=Both if (FAILED(hr)) return hr; hr = RegisterInCategory(); // 挂到 VideoInputDevice 类别 if (FAILED(hr)) { UnregisterComServer(); return hr; } return S_OK; } static HRESULT RegisterInCategory() { CComPtr<IFilterMapper2> pMapper; HRESULT hr = CoCreateInstance(CLSID_FilterMapper2, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pMapper)); if (FAILED(hr)) return hr; REGPINTYPES types[] = { { &MEDIATYPE_Video, &MEDIASUBTYPE_YUY2 } }; REGPINS pin = {}; pin.itype = &PIN_CATEGORY_CAPTURE; // 必须是捕获类别 pin.bFixed = FALSE; pin.bCanReconnect = FALSE; pin.nMediaTypes = 1; pin.lpMediaType = types; REGFILTER2 rf = {}; rf.dwVersion = REGFILTER2_VERSION; rf.dwMerit = MERIT_DO_NOT_USE; // 别跟真实摄像头抢默认 rf.cPins = 1; rf.rgPins = &pin; return pMapper->RegisterFilter(CLSID_VCamFilter, L"VCAM Virtual Camera", nullptr, &CLSID_VideoInputDeviceCategory, nullptr, &rf); }

ThreadingModel填 Both,这个值决定了 COM 在 STA 和 MTA 线程里怎么创建你的对象。填成 Apartment,某些在多线程 MTA 环境下建图的应用会创建失败;填成 Free 又可能跟 STA 的宿主打架。Both 是最省事的折中。

注册的落地脚本没什么花活,位数对齐就行:

:: install.bat —— 管理员权限运行 copy /y bin\vcam_x64.ax "%SystemRoot%\System32\vcam_x64.ax" copy /y bin\vcam_x86.ax "%SystemRoot%\SysWOW64\vcam_x86.ax" "%SystemRoot%\System32\regsvr32.exe" /s "%SystemRoot%\System32\vcam_x64.ax" "%SystemRoot%\SysWOW64\regsvr32.exe" /s "%SystemRoot%\SysWOW64\vcam_x86.ax"

64 位 DLL 放 System32、32 位放 SysWOW64,这个命名是历史遗留,方向跟直觉相反,记反了一定注册失败。卸载脚本反过来跑 regsvr32 /u,最后把文件删掉。

4.3 用最小宿主验证链路

注册完别急着开会议软件,先用自己写的 graphtest 走一遍。它的逻辑很简单:枚举设备、按名字找到虚拟摄像头、CoCreateInstance 建滤镜、AddFilter、手写一个直通滤镜接在输出 Pin 后面、Run 三秒、统计收到的样本数和平均间隔。

// graphtest.cpp —— 最小验证宿主的核心几步 CComPtr<IGraphBuilder> pGraph; pGraph.CoCreateInstance(CLSID_FilterGraph); CComPtr<IBaseFilter> pCam; pCam.CoCreateInstance(CLSID_VCamFilter); // 直接按 CLSID 建,跳过枚举 pGraph->AddFilter(pCam, L"VCAM"); CComPtr<IBaseFilter> pSink; // 直通滤镜,统计帧数 CreateSinkFilter(&pSink); pGraph->AddFilter(pSink, L"Sink"); CComPtr<IPin> pOut = GetPin(pCam, PINDIR_OUTPUT, 0); CComPtr<IPin> pIn = GetPin(pSink, PINDIR_INPUT, 0); pGraph->Connect(pOut, pIn); // 连接失败在这里就能暴露 CComPtr<IMediaControl> pCtrl; pGraph->QueryInterface(&pCtrl); pCtrl->Run(); Sleep(3000); pCtrl->Stop(); wprintf(L"3 秒收到 %d 帧,平均间隔 %.1f ms\n", g_frames, 3000.0 / (g_frames ? g_frames : 1));

这套流程跑通了,说明滤镜本身的枚举、创建、连接、出帧都没有问题,再去接会议软件。反过来,如果 graphtest 都连不上,先怀疑自己的代码,别去猜宿主软件的兼容性——我见过太多人在这上面绕远路。

5. 避坑排查:注册成功却打不开的五种真实情况

这一章是我踩过的、以及帮别人排过的问题,按「现象 → 原因 → 解决」记下来。症状看着都差不多,根因却全不一样。

第一坑:64 位机器上,会议软件里完全看不到设备

现象:注册脚本显示成功,regedit 里也能看到 CLSID 和类别项,但 32 位的应用枚举出来的设备列表里没有它。

原因:只编了 64 位版本。32 位进程加载 32 位 COM 服务器,找不到对应的 InprocServer32 路径就静默跳过,不会报错,枚举器直接把这一项过滤了。

解决:编两份、注册两份,用进程管理器看一眼目标软件是 32 位还是 64 位,心里有个数。验证时拿 graphtest 的 x86 和 x64 两个版本各跑一次,哪个跑不通就说明哪份没注册上。

第二坑:设备能看到,一点就报错或者直接卡死

现象:下拉框里有「VCAM Virtual Camera」,选中后应用提示设备被占用、或者界面转圈然后闪退。

原因:多半是 InprocServer32 的 ThreadingModel 缺失或填错,COM 在创建对象时走了错误的套间策略;也有可能是 DLL 依赖了某个只在开发机上存在的运行库,加载失败。

解决:ThreadingModel 填 Both;用依赖查看工具扫一遍 DLL,把非系统依赖去掉,运行库改成静态链接最保险。这一步不解决,后面所有调试都是白费。

第三坑:连上了,但画面全黑或者花屏

现象:应用正常打开,预览区一片黑,或者画面是彩色条纹、斜着的。

原因:两种可能。一是 FillBuffer 里没等到帧就返回了 S_FALSE,下游认为流结束;二是像素格式对不上,比如协商成 YUY2 但你填的是 BGR32 数据,或者忽略了 stride,按 width × 4 逐行写。

解决:FillBuffer 无论有没有新帧都返回 S_OK,用上一帧或灰底兜底;在 CopyFrameTo 里严格按当前 m_mt 的 subtype 和 stride 走,写完后拿一帧原始数据 dump 成文件看一眼,比在调试器里瞪眼快得多。

第四坑:帧率不稳,画面一顿一顿

现象:静态画面没问题,一动起来能明显看出卡顿,graphtest 统计的间隔忽大忽小。

原因:FillBuffer 里用了固定 Sleep 或者阻塞式等待,跟工作线程自己的调度叠在一起,节拍被拉长;也有可能是共享内存只有一块缓冲,生产者在写、滤镜在读,互相等。

解决:等待超时压到一帧时长的三分之一左右,超时就出旧帧;共享内存做双缓冲甚至三缓冲,写指针和读指针分开,用事件通知而不是轮询。时间戳按帧号算,别按实际流逝时间算,这样即使偶尔掉一帧,后续节拍也能自己追回来。

第五坑:打不开摄像头,或者生产者明明在跑却读不到数据

现象:滤镜侧 OpenFileMapping 一直失败,或者成功但每次 WaitFrame 都超时。

原因:命名加了Global\前缀但生产者以普通用户权限运行,创建被拒;或者生产者和滤镜不在同一个会话(一个在服务里、一个在桌面里);也可能是名字拼写在不经意间多了一个下划线。

解决:桌面场景统一用不带前缀的名字,简单可靠;跨会话的需求用权限更宽的命名对象配合安全描述符,别指望默认设置能过;名字定义成头文件里的常量,两边引用同一个宏,杜绝手抄错误。

6. 二次开发:把静态测试图换成你自己的帧源

拿到这套东西之后,绝大多数人的第一个需求都是「把测试图案换成我自己的画面」。改造点其实只有一个,就是 CFrameSource 那一层,但选哪种接法决定了后面好不好维护。

第一种是保持生产者独立,你的程序作为生产者往共享

本文还有配套的精品资源,点击获取