11 / 32 Source、对象、插件与渲染

第 10 课:采集第一帧画面

# 第 10 课:采集第一帧画面

嘿,我是小方。 模块 2 我们把 OBS 的"世界观"摸透了。从这一课起,进入模块 3:画面是怎么来的(视频链路),顺着流水线追一帧画面走完全程。 第一站是采集。这一课我答应你:把一个真实采集源的每一步都摊开,代码、用到的库、每行在干嘛,全讲清楚 —— 你照着看,就能明白一个摄像头插件到底是怎么写出来的。我们拿 Linux 的 v4l2 摄像头插件开刀(纯 C,最干净;Windows 的 win-dshow 把 DirectShow 包一层,六步一一对应)。

📖 先补:几个采集要用到的系统词 —— 文件描述符(fd):Linux 里"打开的设备/文件"的一个编号,像门牌号;ioctl:对设备下命令的通用函数("设个分辨率""开始采集"都靠它);mmap(内存映射):把设备的缓冲区直接映射到你的内存,读它就等于读设备,省掉一次拷贝(零拷贝),快;plane/linesize:一帧图像按平面存,每行实际字节数叫 linesize(可能比"宽×像素字节"大,有对齐填充)。下面看采集循环时,这些词就不陌生了。


# 10.1 先回到地图:采集是第一站

📍我们在哪:模块 3「视频链路」开篇——顺着流水线追一帧画面,第一站就是采集:把现实世界的光变成一个 obs_source_frame

模块 3 开场:采集是第一站

采集这一站干的事,一句话:把现实世界的光,变成一个个 struct obs_source_frame 第 2 课那个结构体 —— width/height(分辨率)、data(像素字节)、format(像素格式)、timestamp(时间戳)—— 采集源就是把镜头前的画面填进它,再交给引擎。

怎么交?第 6 课讲过两条路:异步推帧(obs_source_output_video)和 GPU 渲染(video_render)。这一课走第一条(更普适,直接续上第 2 课);GPU 那条留到第 11 课。


# 10.2 采集源,就是个 INPUT

📍接上一节:知道了采集要产出 obs_source_frame,这一节先给采集源定位——它就是第 6 课那四类里的 INPUT 源,走异步喂帧那条路。

翻到 plugins/linux-v4l2/v4l2-input.c 那张"登记表":

采集源是个 INPUT 源

// plugins/linux-v4l2/v4l2-input.c:1099
struct obs_source_info v4l2_input = {
	.id           = "v4l2_input",
	.type         = OBS_SOURCE_TYPE_INPUT,              // 它是个「源头」
	.output_flags = OBS_SOURCE_ASYNC_VIDEO              // 走「异步喂帧」那条路
	                | OBS_SOURCE_DO_NOT_DUPLICATE,
	.create       = v4l2_create,                        // 创建实例时调
	.destroy      = v4l2_destroy,
	.get_width    = v4l2_getwidth,
	.get_height   = v4l2_getheight,
	...
};
1
2
3
4
5
6
7
8
9
10
11
12

OBS_SOURCE_ASYNC_VIDEO 这一个标志,就告诉引擎"我会自己抓帧、主动推过来,别每帧来问我要"。那它在哪抓帧?.create(v4l2_create)里会起一个采集线程,真正干活的都在那个线程里。下面我们把那个线程从头到尾拆开。


# 10.3 用到哪些库,以及完整的六步

📍承上启下:知道了它是个 INPUT 源、会起采集线程,这一节先摊开用到哪些库,再列出把摄像头跑起来的完整六步。

先看这个插件 #include 了什么(v4l2-input.c:17),"用了哪些库"一目了然:

#include <fcntl.h>            // open() 的标志位 O_RDWR
#include <sys/ioctl.h>       // ioctl()
#include <sys/select.h>      // select() —— 等设备「有帧了」
#include <linux/videodev2.h> // ★ V4L2:Linux 内核的摄像头接口(所有 VIDIOC_* 命令码、结构体)
#include <libv4l2.h>         // ★ libv4l2:用户态封装库(v4l2_open / v4l2_ioctl / v4l2_mmap …)

#include <obs-module.h>      // OBS 插件 API
#include <util/threading.h>  // OBS 的线程封装
1
2
3
4
5
6
7
8

两个关键的库:

  • <linux/videodev2.h> —— V4L2(Video for Linux 2),内核暴露的摄像头标准接口。你跟摄像头的所有对话,都是对设备文件 /dev/video0ioctl(...),命令码就是 VIDIOC_* 那一串。
  • <libv4l2.h> —— libv4l2,一个用户态封装库。它最大的好处是自动补格式:有些摄像头只会输出冷门像素格式,libv4l2 在用户态偷偷帮你转成常见格式,你的应用就不用对每种奇葩格式都写一遍处理。所以你看代码里是 v4l2_open / v4l2_ioctl / v4l2_mmap(带 v4l2_ 前缀),而不是裸的 open / ioctl / mmap —— 就是在走 libv4l2 这层。

把一个摄像头采集源跑起来,完整是这六步,前五步是"一次性准备",第六步才是不停转的循环:

V4L2 采集的完整步骤

下面逐步上代码。

# 步骤 ①②:打开设备、设置格式

data->dev = v4l2_open(data->device_id, O_RDWR | O_NONBLOCK);  // ① 打开 /dev/video0
1

v4l2_open 就像普通的 open(),把摄像头这个"设备文件"打开,拿到一个文件描述符 data->dev,之后所有 ioctl 都对它发。

然后用 VIDIOC_S_FMT 这个 ioctl 跟设备协商画面格式 —— 你想要多大分辨率、什么像素格式(YUYV?MJPEG?),设备同意了就定下来,确定后的宽、高、像素格式被记进 data->width / data->height / data->pixfmt。这一步是"谈好规格"。

# 步骤 ③:申请并映射缓冲(mmap)

这步是 V4L2 流式采集的精髓。摄像头不会把每帧"拷"给你,而是和你共享一块内存:内核分配几个缓冲,通过 mmap 映射到你的进程地址空间,之后摄像头往里填、你从里读,零拷贝。看真实代码(v4l2-helpers.c:99):

int_fast32_t v4l2_create_mmap(int_fast32_t dev, struct v4l2_buffer_data *buf)
{
	struct v4l2_requestbuffers req;
	memset(&req, 0, sizeof(req));
	req.count  = 4;                         // 要 4 个缓冲(够流水起来,又不浪费)
	req.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
	req.memory = V4L2_MEMORY_MMAP;          // 用 mmap 方式

	if (v4l2_ioctl(dev, VIDIOC_REQBUFS, &req) < 0)   // 向内核「申请」这些缓冲
		return -1;

	buf->count = req.count;
	buf->info  = bzalloc(req.count * sizeof(struct v4l2_mmap_info));

	struct v4l2_buffer map;
	memset(&map, 0, sizeof(map));
	map.type = req.type;
	map.memory = req.memory;
	for (map.index = 0; map.index < req.count; ++map.index) {
		if (v4l2_ioctl(dev, VIDIOC_QUERYBUF, &map) < 0)  // 问出第 index 个缓冲的大小/偏移
			return -1;

		buf->info[map.index].length = map.length;
		buf->info[map.index].start =                     // ★ 把它 mmap 进自己的地址空间
			v4l2_mmap(NULL, map.length, PROT_READ | PROT_WRITE,
			          MAP_SHARED, dev, map.m.offset);

		if (buf->info[map.index].start == MAP_FAILED)
			return -1;
	}
	return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

逐行讲:

  • req.count = 4 + VIDIOC_REQBUFS —— 跟内核说"给我 4 个采集缓冲,用 mmap 方式"。4 个是经验值:足够让"摄像头在填一个、你在读一个"流水起来,又不至于占太多内存。
  • 循环里 VIDIOC_QUERYBUF —— 逐个问出"第 index 个缓冲有多大(map.length)、在设备内存的什么偏移(map.m.offset)"。
  • v4l2_mmap(...) —— 把这个内核缓冲映射进你的进程,返回一个能直接读的指针 start,存进 buf->info[index].start。以后摄像头往这块填一帧,你这边的 start 立刻就能看到,不用拷贝

这就是为什么 10.5 你会看到引擎那边"复制一份"是必要的 —— 因为这块内存是和内核共享的,你读完得赶紧还回去给摄像头填下一帧,不能一直占着。

# 步骤 ④:把帧的"固定信息"准备好(只做一次)

分辨率、像素格式这些每帧都一样的东西,没必要循环里反复填。v4l2_prep_obs_frame(v4l2-input.c:117)在开抓之前一次性填好那个 obs_source_frame:

static void v4l2_prep_obs_frame(struct v4l2_data *data, struct obs_source_frame *frame,
                                size_t *plane_offsets)
{
	memset(frame, 0, sizeof(struct obs_source_frame));

	const enum video_format format = v4l2_to_obs_video_format(data->pixfmt);  // V4L2 格式 → OBS 格式

	frame->width  = data->width;     // 宽(步骤②谈好的)
	frame->height = data->height;    // 高
	frame->format = format;          // 像素格式(第 3 课的 video_format)
	// 按格式算每个平面的「每行字节数」linesize 和「平面偏移」plane_offsets
	switch (data->pixfmt) {
	case V4L2_PIX_FMT_NV12:                          // 两平面 4:2:0
		frame->linesize[0] = data->linesize;        //   Y 平面每行字节
		frame->linesize[1] = data->linesize;        //   UV 平面每行字节
		plane_offsets[1]   = data->linesize * data->height;  // UV 平面从这里开始
		break;
	...
	default:
		frame->linesize[0] = data->linesize;        // 打包格式,就一个平面
		break;
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这里出现的 linesizeplane_offsets,正是第 3 课讲 YUV 时埋的伏笔:像 NV12 这种格式,亮度(Y)和色度(UV)分开放在不同"平面",每个平面"每行多少字节(linesize)"、"从整块内存的哪个偏移开始(plane_offset)",都得告诉引擎,它才知道怎么把这堆字节解读成一幅画面。v4l2_to_obs_video_format 则负责把 V4L2 的格式码翻译成第 3 课那个 video_format 枚举。

# 步骤 ⑤:开始推流

最后,把 4 个缓冲全部 VIDIOC_QBUF(入队,交给摄像头去填),再发一个 VIDIOC_STREAMON,命令摄像头开始往缓冲里填画面。这步由 v4l2_start_capture(v4l2-helpers.c:26)完成。从此,数据就源源不断地来了。


# 10.4 源码深读:第六步,不停转的抓帧循环

📍接上一节:前五步是一次性准备,第六步才是不停转的主循环——这一节逐行拆开采集线程的抓帧循环。

准备就绪,采集线程 v4l2_thread(v4l2-input.c:160)进入主循环。我把它最核心的部分摊开,逐行注:

抓帧循环

struct obs_source_frame out;
v4l2_prep_obs_frame(data, &out, plane_offsets);   // ④ 先把固定信息填好(上一节)

while (os_event_try(data->event) == EAGAIN) {      // 没收到「停止」信号就一直转
	FD_ZERO(&fds);  FD_SET(data->dev, &fds);
	tv.tv_usec = timeout_usec;
	int r = select(data->dev + 1, &fds, NULL, NULL, &tv);  // 等设备「有帧了」(带超时)
	if (r == 0) { /* 超时,可能掉线,记录并重试 */ continue; }

	struct v4l2_buffer buf = {0};
	buf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
	buf.memory = V4L2_MEMORY_MMAP;
	v4l2_ioctl(data->dev, VIDIOC_DQBUF, &buf);     // ① 取走一个「已填好」的缓冲

	out.timestamp = timeval2ns(buf.timestamp);     // ② 这帧的采集时刻
	if (!frames) first_ts = out.timestamp;
	out.timestamp -= first_ts;                     //    首帧归零

	start = data->buffers.info[buf.index].start;   // 这个缓冲映射后的地址(步骤③拿到的)
	for (i = 0; i < MAX_AV_PLANES; ++i)
		out.data[i] = start + plane_offsets[i];    // ③ 让 out.data 指向各平面的字节

	obs_source_output_video(data->source, &out);   // ④ ★ 把这一帧交给引擎

	v4l2_ioctl(data->dev, VIDIOC_QBUF, &buf);      // ⑤ 把缓冲还给摄像头,去填下一帧
	frames++;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

逐句过一遍:

  • select(...) —— 这是个 POSIX 系统调用,作用是"阻塞等待,直到这个设备有数据可读(或超时)"。没帧的时候,采集线程就睡在这,不空转烧 CPU;摄像头填好一帧,select 立刻返回。timeout_usec 是超时(代码里设成约 5 个帧周期),超时多半意味着设备掉线,会去记录/重置。
  • VIDIOC_DQBUF(dequeue buffer)—— 从摄像头手里"取走"一个已经填好画面的缓冲。buf.index 告诉你是 4 个里的哪一个。
  • 时间戳 —— out.timestamp 记下采集时刻,-= first_ts 做首帧归零(第一帧时间戳从 0 算起)。这个时间戳是音画同步的命根子(模块 4)。
  • out.data[i] = start + plane_offsets[i] —— 关键的一步:让 out 的数据指针指向那个缓冲里各平面的起始字节(步骤④算好的 plane_offsets 在这用上了)。注意是"指过去",没有拷贝
  • obs_source_output_video —— 把填好的整帧交给引擎。第 6 课那条"异步出帧路",源头就在这一行。
  • VIDIOC_QBUF(queue buffer)—— 把这个缓冲"还给"摄像头,让它去填下一帧。然后 while 转回去 select 等下一帧。

整个循环就是 等帧 → 取缓冲 → 填 obs_source_frame → 交引擎 → 还缓冲,周而复始,一秒钟转几十圈。这,就是"采集"在做的全部事情。


# 10.5 源码深读:引擎"收下"一帧时做了什么

📍承上启下:采集线程把帧「交」出去之后呢?这一节切到引擎一侧,看 obs_source_output_video 收下一帧时做的两件事。

out.data 只是指向那个马上要被 QBUF 还回去的共享缓冲 —— 引擎要是不赶紧处理,缓冲一被摄像头覆盖,画面就花了。引擎怎么接住?看 obs_source_output_video_internal(obs-source.c:3563):

引擎收帧:cache + 入队

static void obs_source_output_video_internal(obs_source_t *source,
                                             const struct obs_source_frame *frame)
{
	...
	struct obs_source_frame *output = cache_video(source, frame);   // ① 复制进自己的缓存

	pthread_mutex_lock(&source->async_mutex);
	...
	da_push_back(source->async_frames, &output);                    // ② 推进「待显示队列」
	source->async_active = true;
	pthread_mutex_unlock(&source->async_mutex);
}
1
2
3
4
5
6
7
8
9
10
11
12

就两件事:

  • cache_video:复制一份。 这就解开了上面的矛盾 —— 引擎把这帧像素拷进 source 自己的缓存,于是采集线程拷完立刻就能 QBUF 把共享缓冲还给摄像头,根本不用干等。一次拷贝,换来采集线程和引擎彻底松绑。
  • da_push_back(source->async_frames, ...):入队。 把缓存帧推进 async_frames 数组 —— 正是第 6 课"实例剖面图"里那个 async_frames[],采集线程和渲染线程的"交接台"。

注意:output_video 只管"收下并入队",并不在这里把画面画出来。 画,是渲染线程的活儿。两条线程靠 async_frames 队列加一把 async_mutex 锁,安全地隔着传帧。


# 10.6 第一帧的完整旅程:跨线程

📍收束:生产端(采集线程)和消费端(渲染线程)都看过了,这一节把它俩连起来,走一遍第一帧的完整跨线程旅程。

把生产端和消费端连起来:

第一帧的完整旅程

  • 采集线程(生产者):摄像头填好一帧 → DQBUF 取出、填进 obs_source_frameobs_source_output_video(引擎 cache_video 复制)。
  • 中间:帧进 async_frames[] 队列,按时间戳排着。
  • 渲染线程(消费者):按"当前该显示哪一帧"的时间戳取一帧 → 上传成 GPU 纹理(第 11 课)→ 交给"合成"叠进画面 → 显示。

这个队列把"设备来帧的节奏"和"屏幕刷新的节奏"解耦了:摄像头 30fps 不太稳、屏幕 60Hz 刷,两边对不上没关系,队列在中间缓冲,渲染线程到点了按时间戳取最合适那帧。音画同步也在这一层做(模块 4)。


# 10.7 回到第 6 课:采集的两条路

📍收束:采集讲完,回扣第 6 课那「两条出帧路」——本课走的是异步 RAM 帧,另一条 GPU 纹理正好引出下一课。

两条采集路

  • ① 异步 RAM 帧(本课) —— 摄像头(v4l2 / win-dshow)、视频文件(obs-ffmpeg)。帧在内存里产生,obs_source_output_video 推给引擎,进 async_frames 队列。
  • ② GPU 纹理(第 11 课) —— 显示器采集、游戏采集。画面本就在显存里,走 video_render 回调,引擎每帧主动调它直接在显卡上画,不走内存搬运,更快。

下一课,我们就钻进 GPU 那条。


# 10.8 本课小结

  • 采集 = 把现实画面填进 struct obs_source_frame,交给引擎;采集源是 INPUT + OBS_SOURCE_ASYNC_VIDEO(v4l2-input.c:1099)。
  • 用到的库:<linux/videodev2.h>(V4L2 内核接口,VIDIOC_* ioctl)+ <libv4l2.h>(用户态封装,自动补格式);POSIX 的 open/ioctl/select/mmap。Windows 上是 win-dshow + DirectShow,六步一一对应。
  • 六步配方:① v4l2_open 开设备 → ② VIDIOC_S_FMT 设格式 → ③ VIDIOC_REQBUFS + mmap 申请映射缓冲(零拷贝共享内存)→ ④ v4l2_prep_obs_frame 填好 width/height/format/linesize(一次)→ ⑤ QBUF×N + STREAMON 开推流 → ⑥ 抓帧循环。
  • 抓帧循环:select 等帧 → VIDIOC_DQBUF 取缓冲 → 填时间戳(首帧归零)+ out.data 指向各平面 → obs_source_output_video 交帧 → VIDIOC_QBUF 还缓冲。
  • 引擎收帧(obs-source.c:3563):cache_video 复制(让采集线程能立刻还缓冲)+ da_push_back(async_frames) 入队;不负责画。
  • 跨线程:采集线程产 → async_frames 队列 → 渲染线程按时间戳取 → GPU 纹理 → 合成。队列解耦"设备节奏/屏幕节奏",也是音画同步层。

# 10.9 动手 / 观察(本课作业)

  1. 加个摄像头源:OBS → 添加来源 →「视频采集设备」。你加的就是本课这个 ASYNC_VIDEO 的 INPUT 源。
  2. 对照另一个异步源:在 plugins/obs-ffmpeg/obs-ffmpeg-source.cobs_source_output_video,看视频文件(也是异步源)在哪、怎么把解码出的一帧交给引擎 —— 跟摄像头是不是同一个套路?
  3. 想清楚那次拷贝:用自己的话讲明白,为什么 obs_source_output_video_internal 里那句 cache_video必须的。(提示:步骤③那块内存是和谁共享的?第六步最后那句 QBUF 之后,这块内存还归你吗?)
  4. (进阶)数一数 ioctl:本课的六步里,一共用到了哪几个 VIDIOC_* 命令?各自在哪一步、干什么?

# 下一课预告

这一课的帧,最后"上传成 GPU 纹理"就交给了下一站。可 GPU 到底怎么回事?纹理是什么?屏幕/游戏采集为什么能不走内存、直接在显卡上画?以及 OBS 怎么用同一套代码,在 Windows(D3D11)、Linux(OpenGL)、macOS(Metal)三种显卡上干活?

第 11 课:GPU 与图形子系统 —— 钻进 libobs/graphics/,看 OBS 怎么把"在显卡上画个东西"抽象成统一接口,以及一帧画面在 GPU 上合成的真实过程。下节课见。


📁 本课配图:imgs/10-01 ~ imgs/10-07 📌 源码锚点:plugins/linux-v4l2/v4l2-input.c:17(头文件/库)、:117(prep_obs_frame)、:160(v4l2_thread)、:235(抓帧循环)、:1099(obs_source_info)、plugins/linux-v4l2/v4l2-helpers.c:26(start_capture/STREAMON)、:99(create_mmap)、libobs/obs-source.c:3563(output_video_internal)、:3595(obs_source_output_video)、libobs/obs.h:283(obs_source_frame)

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>