尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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把候选类型交出来下游挑一个它认识的调 ReceiveConnectionPin 侧在 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.batsrc 里的四个文件是核心。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; // 1BGR32, 2NV12, 3YUY2 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是帧率的倒数单位 100ns30fps 就填 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 SRCsrc\vcam.cpp src\vcamfilter.cpp src\vcamstream.cpp src\vcamprops.cpp src\framesource.cpp cl /nologo /EHsc /LD /MD /D_WIN32_WINNT0x0601 /DWIN32 ^ /I%DXSDK_INC% /Isrc %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_WINNT0x0601 /DWIN32 ^ /I%DXSDK_INC% /Isrc %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 滤镜的惯例本质还是 DLLregsvr32 一样认。/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(); // 写 InprocServer32ThreadingModelBoth if (FAILED(hr)) return hr; hr RegisterInCategory(); // 挂到 VideoInputDevice 类别 if (FAILED(hr)) { UnregisterComServer(); return hr; } return S_OK; } static HRESULT RegisterInCategory() { CComPtrIFilterMapper2 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, LVCAM 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.ax64 位 DLL 放 System32、32 位放 SysWOW64这个命名是历史遗留方向跟直觉相反记反了一定注册失败。卸载脚本反过来跑 regsvr32 /u最后把文件删掉。4.3 用最小宿主验证链路注册完别急着开会议软件先用自己写的 graphtest 走一遍。它的逻辑很简单枚举设备、按名字找到虚拟摄像头、CoCreateInstance 建滤镜、AddFilter、手写一个直通滤镜接在输出 Pin 后面、Run 三秒、统计收到的样本数和平均间隔。// graphtest.cpp —— 最小验证宿主的核心几步 CComPtrIGraphBuilder pGraph; pGraph.CoCreateInstance(CLSID_FilterGraph); CComPtrIBaseFilter pCam; pCam.CoCreateInstance(CLSID_VCamFilter); // 直接按 CLSID 建跳过枚举 pGraph-AddFilter(pCam, LVCAM); CComPtrIBaseFilter pSink; // 直通滤镜统计帧数 CreateSinkFilter(pSink); pGraph-AddFilter(pSink, LSink); CComPtrIPin pOut GetPin(pCam, PINDIR_OUTPUT, 0); CComPtrIPin pIn GetPin(pSink, PINDIR_INPUT, 0); pGraph-Connect(pOut, pIn); // 连接失败在这里就能暴露 CComPtrIMediaControl pCtrl; pGraph-QueryInterface(pCtrl); pCtrl-Run(); Sleep(3000); pCtrl-Stop(); wprintf(L3 秒收到 %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 那一层但选哪种接法决定了后面好不好维护。第一种是保持生产者独立你的程序作为生产者往共享本文还有配套的精品资源点击获取
RELATED

相关推荐

Vue事件冒泡导致文件上传弹窗两次?用@click.stop彻底解决

Vue事件冒泡导致文件上传弹窗两次?用@click.stop彻底解决

1. 事件冒泡为什么会让文件选择框弹两次先说个我实际踩过的场景。某后台管理项目里,我需要做一个卡片式的上传入口:整张卡片可以点击进入详情页,卡片右上角有一个上传按钮。结果上线后测试同事反馈,点一次上传按钮会弹两次文件选择…

📅 2026/10/24 6:00:58
基于Arduino与ESP8266的温湿度及PM2.5环境监测系统设计

基于Arduino与ESP8266的温湿度及PM2.5环境监测系统设计

拿到这个标题,第一反应就是典型的“毕业设计/课程设计热门款”加“个人DIY环境监测入门标配”。但说真的,把它做好做稳并不简单,很多照着网上教程接线的人最后都卡在数据飘、连不上网、读数对不齐这几个地方。我这篇不打算只给一份能用就行的…

📅 2026/10/24 6:00:58
Qt节点式视觉工具链:QGraphicsScene流程编辑器源码解析

Qt节点式视觉工具链:QGraphicsScene流程编辑器源码解析

做视觉项目这些年,我越来越觉得“工具链搭建”和“流程编排”是个绕不开的坎。尤其是做工业检测类的上位机软件,常常需要把相机采集、图像预处理、特征提取、缺陷判定、结果输出这些步骤串起来。最朴素的实现是写死一段流程函数,今天要加一个…

📅 2026/10/24 6:00:58
MORE NEWS

更多资讯

📰

快降重理工科降AI技术:实证数据与推导逻辑的保护机制详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

YimMenu:GTA V 防崩溃菜单,怎么编译注入、哪些功能敢开

YimMenu:GTA V 防崩溃菜单,怎么编译注入、哪些功能敢开 【免费下载链接】YimMenu YimMenu, a GTA V menu protecting against a wide ranges of the public crashes and improving the overall experience. 项目地址: https://gitcode.com/GitHub_Tren…

📰

Visual Studio 添加万能头文件 bits/stdc++.h:从原理到配置全程指南

做算法题的朋友应该都有这个习惯:从题单里复制一道题目,打开 Visual Studio,第一件事不是读题,而是先伸手去敲开头那串#include。iostream、algorithm、vector、map、string、queue……少敲一个心里都不踏实,敲完了还要…

📰

Windows平台frpc客户端架构选择与性能优化指南

1. frpc客户端架构差异解析在Windows平台上部署frp内网穿透服务时,我们常会遇到两个不同架构的客户端版本:frpc_win_amd64和frpc_win_arm64。这两个版本的核心区别在于CPU指令集架构的兼容性设计。1.1 x86_64与ARM64架构对比AMD64(又称x86_64…

📰

PaddleHelix 分子模型上手指南:GeoGNN 与 SD VAE 两个核心组件从原理到跑通

PaddleHelix 分子模型上手指南:GeoGNN 与 SD VAE 两个核心组件从原理到跑通 【免费下载链接】PaddleHelix Bio-Computing Platform Featuring Large-Scale Representation Learning and Multi-Task Deep Learning “螺旋桨”生物计算工具集 项目地址: https://git…

📰

企业级数据可视化BI 仪表板数据中台监控平台的选择分析

三类场景决策模型产品推荐场景1:简单BI展示推荐使用ECharts原因:免费快速场景2:企业级BI平台推荐使用Highcharts原因:可扩展可维护有完整生态不同场景类型选择的真正的差异是什么?1. 使用 ECharts是用“代码能力”2. 使…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬