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

第 14 课:从合成结果到一帧成品 —— 视频管线与转场

# 第 14 课:从合成结果到一帧成品 —— 视频管线与转场

嘿,我是小方。 第 13 课末尾,我们把一堆源合成成了一张纹理(住在显存里的"画布")。可它就那么静静躺在显存里,后面什么都还没发生。这一课要回答两个一直悬着的问题: 一、这张画布,是谁、隔多久产出"一帧成品"、送进后面的编码的? 二、你从"场景 A"切到"场景 B"时,那个淡入淡出、滑动的过渡,到底是怎么做出来的? 答完这两个,视频链路的前半程(从摄像头的光,到交给编码器那一刻)就彻底通了。这是模块 3 真正的收官。


# 14.1 背景:合成好了,然后呢?

📍接上一节:上一课我们把画面合成进了一张纹理,可它就那么躺在显存里——这一节先把这一课要回答的两个问题摆出来。

先接上第 13 课。那张合成好的"主纹理",不会自己变成视频。它需要有人定时来取——按你设的帧率(比如 60fps),每 1/60 秒取走一帧,交给编码器。

合成好了,然后呢

这一课就顺着这条线走:

  • 引擎的"心跳":一根专门的线程,每 1/fps 秒转一圈,合成一帧、然后精确地睡到下一拍(14.2)。
  • 从显存到"一帧":合成结果在 GPU 里,怎么交到编码器手上?有两条路(14.3)。
  • 视频输出管线 video_output:一条"生产者塞帧、消费者订阅收帧"的流水线,还负责把会抖动的渲染节奏,整理成严格贴着帧率的一串帧(14.4、14.5)。
  • 转场:切场景时那个过渡,其实是"又一个夹在中间的 source"(14.6、14.7)。

我们一个一个来。


# 14.2 渲染循环:引擎的"心跳"

📍我们在哪:先回答第一个问题——那张主纹理是谁、隔多久取一次的?答案是一根按帧率跳动的图形线程,引擎的心跳。

OBS 里有一根图形线程(graphics thread),它就是整个画面的心跳。它做的事极其规律:转一圈 = 产一帧,然后精确地睡到下一帧该开始的时刻,再转下一圈。

渲染循环

# 一圈干了什么

线程入口是 obs_graphics_thread(obs-video.c:1163),它进来先取好帧间隔,然后就是一个死循环,不停地调 obs_graphics_thread_loop:

// libobs/obs-video.c:1163(节选)
void *obs_graphics_thread(void *param)
{
	const uint64_t interval = obs->video.video_frame_interval_ns;   // ← 每帧多少纳秒
	obs->video.video_time = os_gettime_ns();
	...
	struct obs_graphics_context context;
	context.interval = interval;
	...
	while (obs_graphics_thread_loop(&context))   // ← 一圈 = 一帧,直到退出
		;
}
1
2
3
4
5
6
7
8
9
10
11
12

obs_graphics_thread_loop(obs-video.c:1099)就是一帧的全过程,抽出主干:

// libobs/obs-video.c:1099(抽主干)
bool obs_graphics_thread_loop(struct obs_graphics_context *context)
{
	...
	tick_sources(obs->video.video_time, context->last_time);   // ① 推进所有源到当前时刻
	...
	output_frames();       // ② 合成:把画面画进主纹理(第 13 课那套)
	render_displays();     // ③ 顺便把预览窗也画一遍
	...
	video_sleep(&obs->video, &obs->video.video_time, context->interval);  // ④ ★ 睡到下一拍
	...
	return !stop_requested();
}
1
2
3
4
5
6
7
8
9
10
11
12
13

四步,逐个说:

  • tick_sources(:1114) —— "tick"就是"走一拍"。它把所有源推进到当前这一时刻:比如一个视频源该播到第几帧了、一个动画滤镜该到哪一步了。先对齐时间,再画,画出来的才是"这一时刻该有的样子"。
  • output_frames(:1127) —— 真正的合成。它内部对每一路输出(mix)调 output_frame,一路走到 render_main_texture,而里面那句(马上第 14.3 细看)就是第 13 课的 obs_view_render —— 把当前场景逐项画进主纹理。
  • render_displays(:1131) —— 把预览窗口也刷新一遍(你在 OBS 界面看到的那个实时画面)。
  • video_sleep(:1144) —— 心跳的关键:睡到下一帧该开始的精确时刻。这一步决定了整个循环的节奏。

# 心跳靠什么"踩点":os_sleepto_ns

video_sleep(obs-video.c:807)是这颗心脏的节拍器。它的核心就几行:

// libobs/obs-video.c:807(节选)
static inline void video_sleep(struct obs_core_video *video, uint64_t *p_time, uint64_t interval_ns)
{
	struct obs_vframe_info vframe_info;
	uint64_t cur_time = *p_time;
	uint64_t t = cur_time + interval_ns;   // 下一帧应当开始的绝对时刻
	int count;

	if (os_sleepto_ns(t)) {        // ★ 一直睡到时刻 t
		*p_time = t;
		count = 1;             // 准点醒来:这一拍就是一帧
	} else {
		// 没睡到点(说明这一圈渲染太慢、已经超时了):
		const uint64_t udiff = os_gettime_ns() - cur_time;
		int64_t diff;
		memcpy(&diff, &udiff, sizeof(diff));
		const uint64_t clamped_diff = (diff > (int64_t)interval_ns) ? (uint64_t)diff : interval_ns;
		count = (int)(clamped_diff / interval_ns);   // 落后了几帧
		*p_time = cur_time + interval_ns * count;
	}

	video->total_frames += count;
	video->lagged_frames += count - 1;

	vframe_info.timestamp = cur_time;
	vframe_info.count = count;         // ← 这个 count 后面要用(补帧)
	...
}
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

一句话读懂:

  • os_sleepto_ns(t) —— 不是"睡多少纳秒",而是"睡到绝对时刻 t"。这比"睡固定一段"精确得多:哪怕这一圈渲染花的时间忽长忽短,只要都睡到同一个网格点上,整体节奏就稳稳锁在帧率上。它是平台封装的睡眠函数(Windows 上底层是高精度等待,Linux 上是 clock_nanosleep 之类),OBS 用 os_ 前缀把各平台差异抹平(这套路和第 9、11 课的 os_dlopen、第 8 课的跨线程封装一脉相承)。
  • 准点 → count = 1:正常情况,这一拍产出一帧。
  • 没准点 → count = 落后的帧数:如果这一圈渲染太慢、os_sleepto_ns 醒来时已经错过了好几个网格点,就算一算落后了几帧,记进 count这个 count 是后面"补帧"的依据(14.5 见分晓)。

# interval 从哪来?就是帧率的倒数

那个"每帧多少纳秒"的 interval,是配置里帧率的直接倒数,在初始化视频时算好(obs.c:692):

// libobs/obs.c:692
video->video_frame_interval_ns = util_mul_div64(1000000000ULL, ovi->fps_den, ovi->fps_num);
1
2

1e9 纳秒 × (fps_den / fps_num):60fps(fps_num=60, fps_den=1)→ 每帧 1e9/60 ≈ 16 666 666 纳秒 ≈ 16.67 毫秒;30fps → 33.3 毫秒。你在 OBS 设置里选的那个"帧率",最终就变成了这一个数,决定心跳多快跳一下。


# 14.3 从 GPU 纹理到"一帧":两条路

📍承上启下:心跳每跳一下就合成好一张纹理,可编码器不一定吃得下显存里的纹理——这一节看它怎么交出去,软件、硬件各走一条路。

合成好的画面此刻在显存里(一张 GPU 纹理)。可编码器不一定能直接吃显存里的纹理——这取决于用的是软件编码还是硬件编码。于是 OBS 分了两条路

两条路

先看合成落在哪。render_main_texture(obs-video.c:172)把渲染目标切到主纹理,再调第 13 课那句:

// libobs/obs-video.c:172(节选)
static inline void render_main_texture(struct obs_core_video_mix *video)
{
	...
	gs_set_render_target_with_color_space(video->render_texture, NULL, video->render_space);  // 把画布设成渲染目标
	gs_clear(GS_CLEAR_COLOR, &clear_color, 1.0f, 0);
	set_render_size(base_width, base_height);
	...
	obs_view_render(video->view);   // ← 第 13 课:逐个场景项画进这张 render_texture
	video->texture_rendered = true;
}
1
2
3
4
5
6
7
8
9
10
11

画完,render_video(obs-video.c:539)接着把这张纹理缩放、转成 YUV(编码器爱吃 YUV,回顾第 3 课),然后分叉成两条路:

# 路 A:下载回内存(软件编码走这条)

软件编码器(x264,在 CPU 上跑)需要的是内存里的像素,所以要把画面从显存"下载"回来:

  1. stage_output_texture(obs-video.c:406) —— 发起一次 GPU→**暂存面(staging surface)**的拷贝(gs_stage_texture)。暂存面是一块"CPU 能读到"的显存,专门用来往回倒数据。
  2. download_frame(obs-video.c:585) —— 下一帧的时候,把暂存面映射进普通内存:
    // libobs/obs-video.c:593
    if (!gs_stagesurface_map(surface, &frame->data[channel], &frame->linesize[channel]))
    
    1
    2
    gs_stagesurface_map 之后,frame->data 就是一个你能用 CPU 直接读的像素指针了。(为什么隔一帧才读?因为 GPU→CPU 的回读有延迟,当帧就读会卡住等 GPU;错开一帧,等它悄悄传完再取,不阻塞。)
  3. output_video_data(obs-video.c:779) —— 把这帧内存像素塞进视频输出管线(下一节的主角):
    // libobs/obs-video.c:779(节选)
    static inline void output_video_data(struct obs_core_video_mix *video, struct video_data *input_frame, int count)
    {
        ...
        locked = video_output_lock_frame(video->video, &output_frame, count, input_frame->timestamp);  // 借一格
        if (locked) {
            if (video->gpu_conversion)
                set_gpu_converted_data(&output_frame, input_frame, info);   // 拷 YUV 各平面
            else
                copy_rgbx_frame(&output_frame, input_frame, info);
            video_output_unlock_frame(video->video);   // 还回去 + 通知
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    注意那个 count 参数——正是 14.2 里 video_sleep 算出来的"落后了几帧"。它一路被带到这里。

# 路 B:纹理直接喂显卡(硬件编码走这条)

硬件编码器(NVENC、QSV、AMF——显卡里专门的编码电路)能直接吃显存里的纹理,那还下载回内存干嘛?多此一举,还慢。于是这条路全程不碰 CPU 内存:

  1. output_gpu_encoders(obs-video.c:519) —— 把"共享纹理"交给 GPU 编码路径。
  2. queue_frame(obs-video.c:442) —— 把共享纹理入队gpu_encoder_queue
  3. os_sem_post(obs-video.c:504) —— "叮"一下(信号量 = 一个能"叫醒等待方"的计数器信号),唤醒专门的 GPU 编码线程来取。
// libobs/obs-video.c:504
os_sem_post(video->gpu_encode_semaphore);
1
2

一句话对比:软件编码要"把画搬回内存"(路 A),硬件编码"让显卡就地把纹理编掉"(路 B)。 这也解释了为什么开硬件编码 CPU 占用低——画面根本没往 CPU 那边搬。


# 14.4 video_output:一条带"订阅"的流水线

📍我们在哪:上一节软件那条路把帧塞进了一个叫 video_output 的东西,这一节拆开它——一条生产者塞帧、编码器订阅收帧的流水线。

上一节路 A 末尾那个 video_output_lock_frame / unlock_frame,把帧塞进了一个叫 video_output(类型 video_t) 的东西。它是一条生产者/消费者流水线:渲染线程当生产者往里塞帧,编码器当消费者订阅、收帧。

video_output 流水线

这个结构本身(media-io/video-io.c:70)有三个关键部件:

// libobs/media-io/video-io.c:70(节选关键字段)
struct video_output {
	...
	os_sem_t *update_semaphore;                 // 有新帧就「叮」一下,唤醒消费线程
	uint64_t frame_time;                        // 每帧多少纳秒(又是帧率倒数)
	volatile long skipped_frames;               // 丢了多少帧
	volatile long total_frames;                 // 一共交付了多少帧
	...
	DARRAY(struct video_input) inputs;          // ★ 订阅者名单(每个编码器一项)
	...
	struct cached_frame_info cache[MAX_CACHE_SIZE];  // ★ 帧的环形缓冲
};
1
2
3
4
5
6
7
8
9
10
11
12

# 生产者:塞一帧进去

塞帧分两步——借一格、填像素、还回去、叮一声:

// libobs/media-io/video-io.c:547(节选)
void video_output_unlock_frame(video_t *video)
{
	pthread_mutex_lock(&video->data_mutex);
	video->available_frames--;
	os_sem_post(video->update_semaphore);   // ★ 通知:有新帧了!
	pthread_mutex_unlock(&video->data_mutex);
}
1
2
3
4
5
6
7
8

video_output_lock_frame(:509)从 cache[] 环形缓冲(首尾相接、循环复用的一段固定缓冲区)里借一个空格子给你填;填完 video_output_unlock_frame(:547)把它标记为"就绪",并 os_sem_post 一下——唤醒下面那根消费线程。

# 消费者:一根线程,等"叮"、发帧

video_output_open 时(:259)启动了一根内部线程 video_thread(media-io/video-io.c:190)。它的一辈子就是"睡着等叮、醒来发帧":

// libobs/media-io/video-io.c:190(节选)
static void *video_thread(void *param)
{
	struct video_output *video = param;
	...
	while (os_sem_wait(video->update_semaphore) == 0) {   // ★ 睡着,直到被「叮」醒
		if (video->stop)
			break;
		...
		while (!video->stop && !video_output_cur_frame(video)) {   // 把就绪的帧全发出去
			os_atomic_inc_long(&video->total_frames);
		}
		os_atomic_inc_long(&video->total_frames);
		...
	}
	return NULL;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

真正"发帧给每个订阅者"的是 video_output_cur_frame(media-io/video-io.c:126):

// libobs/media-io/video-io.c:126(节选核心循环)
static inline bool video_output_cur_frame(struct video_output *video)
{
	struct cached_frame_info *frame_info = &video->cache[video->first_added];   // 取最老的就绪帧
	...
	for (size_t i = 0; i < video->inputs.num; i++) {          // ★ 遍历每个订阅者
		struct video_input *input = video->inputs.array + i;
		struct video_data frame = frame_info->frame;

		uint32_t skip = input->frame_rate_divisor_counter++;   // 降帧率:比如输出流 30fps、录制 60fps
		if (input->frame_rate_divisor_counter == input->frame_rate_divisor)
			input->frame_rate_divisor_counter = 0;
		if (skip)
			continue;

		if (scale_video_output(input, &frame))     // 按这个订阅者要的尺寸/格式缩放转换
			input->callback(input->param, &frame); // ★★ 把帧交给它!(编码器的 receive_video)
	}
	...
	frame_info->frame.timestamp += video->frame_time;
	complete = --frame_info->count == 0;    // ← 14.2 那个 count:发够 count 次才算完
	...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

看那句 input->callback(input->param, &frame) —— 这就是把一帧交到编码器手里的那一下。每个订阅者可以有自己的 frame_rate_divisor(降帧因子):比如你推流 30fps、本地录制 60fps,同一条管线两个订阅者,一个每帧都收、一个隔一帧收。

# 编码器是怎么"订阅"上来的

订阅这个动作,发生在编码器启动时。链路是:obs_encoder_startadd_connection(obs-encoder.c:349)→ 分软/硬两条(:360-364):

// libobs/obs-encoder.c:360(节选)
if (gpu_encode_available(encoder)) {
	start_gpu_encode(encoder);       // 硬件:走 14.3 的路 B
} else {
	start_raw_video(encoder->media, &info, encoder->frame_rate_divisor, receive_video, encoder);  // 软件:订阅管线
}
1
2
3
4
5
6

软件这条 start_raw_video(obs.c:3101)最终调 video_output_connect2(video-io.c:398),往 inputs[]da_push_back 一个 video_input,它的 callback 就是 receive_video(obs-encoder.c:1478)。

从此每来一帧,video_thread 就替这个编码器调一次 receive_video 编码器"躺着"就有帧源源不断送上门——这跟第 8 课的信号/发布-订阅是同一套思想:一方 post,多方 callback,彼此解耦。video_output 就是专为"每秒几十帧、要严格计时"这个场景,量身定做的一条发布-订阅流水线。


# 14.5 丢帧与补帧:怎么严丝合缝踩在帧率上

📍承上启下:流水线会收帧了,可渲染节奏是会抖的、编码要的却是稳——这一节回收 14.2 那个 count 伏笔,看它怎么把抖动整理成整齐的帧流。

现在回收 14.2 那个 count 的伏笔。有个现实问题:渲染节奏是会抖的——某一帧场景特别复杂、GPU 忙不过来,这一圈就慢了;可编码器那头要的是严格按帧率、不多不少的一串帧(否则封装出来的视频时间轴会乱、播放会忽快忽慢)。谁来把"会抖的渲染"整理成"整齐的帧流"?就是 video_output 这套 count + skipped 机制。

丢帧与补帧

三种情况:

  • 准点(count = 1) —— os_sleepto_ns 睡到点、没卡,这一拍产出一帧,正常交付。绝大多数时候都是这样。
  • 卡了 → 补帧(count > 1) —— 渲染慢了、睡过了头。video_sleep 算出"落后了 N 帧",把 count = N 一路带到 video_output_lock_frame。于是这一帧在 cache 里被发够 N 次才算完(看 14.4 那句 complete = --frame_info->count == 0)——同一画面复用几次,把缺的帧位填上,让下游时间戳仍然稳稳落在帧率网格上。宁可画面"卡一下不动",也不能让帧率乱掉。
  • 追不上 → 丢帧(skipped) —— 如果 cache 环形缓冲塞满了(消费端来不及取),新帧进不来,就把它标记 skipped,skipped_frames++这就是 OBS 状态栏里那个"跳过的帧 / Skipped frames"——你看到它涨,就说明电脑合成这一路已经追不上你设的帧率了。

一句话:渲染的节奏会抖,但编码要的是稳。count(补)+ skipped(丢),把抖动的渲染,整理成一条严格贴着帧率的帧流。 这份"整理"的活,正是 video_output 存在的核心价值之一——它不只是个传送带,还是个节拍校准器

🗒️ 术语速记:补帧/count=渲染慢了、同一帧重复输出凑够帧率;丢帧/skipped=渲染跟不上、丢掉来不及的帧(状态栏那个 Skipped);staging(暂存)=把 GPU 上的画面下载回内存的中转;信号量=跨线程"叮一下叫醒对方"的信号。


# 14.6 转场:又一个"夹在中间"的 source

📍转折:视频管线讲完了,回头答第二个问题——切场景时那个淡入淡出,其实又是"一个夹在中间的 source"。

换个话题,回答第二个问题:切场景时那个淡入淡出,怎么做的?

你可能以为转场要一套特殊机制。并没有。 转场就是又一个 source——一个"手里攥着两个场景(A 和 B)"的 source。这和第 12 课的滤镜(包着一个源的源)、第 13 课的场景(包着一串源的源)是同一招

转场是夹在中间的 source

平时,引擎的输出通道 0(obs_set_output_source(0, ...))直接指向"当前场景"。你一点"切到场景 B",OBS 就把通道 0 改指向一个转场 source,让它临时"夹在中间"。这个转场 source 内部,拿一个 2 元数组攥着 A 和 B:

// libobs/obs-internal.h:968(节选,source 结构里的转场相关字段)
obs_source_t *transition_sources[2];   // [0] = 从哪来(A),[1] = 到哪去(B)
...
uint64_t transition_start_time;        // 转场开始的时刻
uint64_t transition_duration;          // 转场总时长
gs_texrender_t *transition_texrender[2]; // A、B 各自的离屏纹理
1
2
3
4
5
6

而这个 [0] / [1] 的下标,就是那个一眼能看懂的枚举(obs.h:1576):

enum obs_transition_target {
	OBS_TRANSITION_SOURCE_A,   // = 0
	OBS_TRANSITION_SOURCE_B,   // = 1
};
1
2
3
4

转场开始obs_transition_start(obs-source-transition.c:322),它做两件事:把时钟点着、把目标场景 B 装进槽 1:

// libobs/obs-source-transition.c:357(节选)
if (!active || (!same_as_dest && !same_as_source)) {
	transition->transition_start_time = os_gettime_ns();               // ① 记下开始时刻
	transition->transition_duration = (uint64_t)duration_ms * 1000000ULL;  // 时长(毫秒→纳秒)
}
...
set_source(transition, OBS_TRANSITION_SOURCE_B, dest, activate_transition);  // ② 目标场景装进槽 B
1
2
3
4
5
6
7

前端的入口是 OBSBasic::TransitionToScene(frontend/widgets/OBSBasic_Transitions.cpp:267),你在界面上点场景、按快捷键,最后都汇到那句 obs_transition_start(:365)。转场跑完(t 到 1),B 被"提升"成新的 A,通道 0 也就等价于直接指向场景 B 了。

因为转场是 source,它享受了和场景一样的所有便利:引擎照常每帧调它的 video_render、照常参与合成、照常存档——零特殊处理。又一次,"万物皆 source"把一件看着特殊的事,消化成了"再普通不过的一个源"。


# 14.7 转场源码深读:进度 t 与一句 lerp(A, B, t)

📍我们在哪:知道转场是个攥着 A、B 的 source 了,这一节钻进它每帧干的事——算进度 t,再用一句 lerp 把两幅画面按比例混起来。

转场的灵魂,是一个从 0 走到 1 的进度 t,和"按 t 混合两幅画面"这一下。我们把它拆到底。

转场混合

# 进度 t 怎么算

calc_time(obs-source-transition.c:426)把"已经过了多久 ÷ 总时长",夹到 0~1:

// libobs/obs-source-transition.c:426
static float calc_time(obs_source_t *transition, uint64_t ts)
{
	if (transition->transition_mode == OBS_TRANSITION_MODE_MANUAL)   // 手动(T 型条拖动)另算
		return transition->transition_manual_val;

	uint64_t end;
	if (ts <= transition->transition_start_time)   // 还没开始 → 0
		return 0.0f;

	end = transition->transition_duration;
	ts -= transition->transition_start_time;       // 已经过了多久
	if (ts >= end || end == 0)                     // 到点/超时 → 1
		return 1.0f;

	return (float)((long double)ts / (long double)end);   // ★ 核心:elapsed / duration
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

就是 t = (现在 - 开始) / 总时长,天然被夹在 [0, 1]。喂给它的"现在",是当前这一帧的时钟 obs->video.video_time(get_video_time,:444)——所以每一帧,t 都往前挪一点点,画面就动起来了。(纯 calc_time 出来的是线性进度;像 slide、swipe 那些带缓动的转场,会再套一层 cubic_ease_in_out(plugins/obs-transitions/easings.h:3)把它揉成"先慢后快再慢";最朴素的 fade 不加缓动,直接用线性 t。)

# 每帧:画两张纹理,交给插件混合

核心在 obs_transition_video_render2(obs-source-transition.c:653)。它做三件事——算进度、把 A 和 B 各画成一张纹理、把两张纹理连同 t 交给插件的回调:

// libobs/obs-source-transition.c:653(抽核心)
void obs_transition_video_render2(obs_source_t *transition,
                                  obs_transition_video_render_callback_t callback,
                                  gs_texture_t *placeholder_texture)
{
	...
	float t = get_video_time(transition);   // ① 当前进度

	if (t >= 1.0f && transition->transitioning_video) {   // ② 到点了 → 自动收尾
		transition->transitioning_video = false;
		...
	}
	...
	gs_texture_t *tex[2];
	for (size_t i = 0; i < 2; i++) {          // ③ 把 A、B 分别画进各自的离屏纹理
		if (state.s[i]) {
			render_child(transition, state.s[i], i, source_space);
			tex[i] = get_texture(transition, i);   // 拿到画好的纹理
			...
		}
	}
	...
	gs_blend_function(GS_BLEND_ONE, GS_BLEND_INVSRCALPHA);
	callback(transition->context.data, tex[0], tex[1], t, cx, cy);   // ★ 交给插件:两张纹理 + 进度 t
	...
}
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

注意:libobs 自己不混合。它只负责把 A 画成 tex[0]、B 画成 tex[1](每个都是第 13 课那套 render_childobs_source_video_render),然后把两张纹理和 t 一起,丢给具体的转场插件去合。怎么合,才是每种转场(淡入淡出、滑动、擦除)各显神通的地方。

# 最简单的转场:fade,一句 lerp

看最朴素的 fade(淡入淡出)插件。它注册成一个 type = OBS_SOURCE_TYPE_TRANSITION 的 source(transition-fade.c:139),它的 video_render 就一句话——把活儿转交给上面那个 render2,并塞进自己的回调:

// plugins/obs-transitions/transition-fade.c:92
static void fade_video_render(void *data, gs_effect_t *effect)
{
	struct fade_info *fade = data;
	obs_transition_video_render2(fade->source, fade_callback, NULL);   // 把 fade_callback 交进去
}
1
2
3
4
5
6

真正的混合在 fade_callback(transition-fade.c:51)。它拿到 A、B 两张纹理和进度 t,把 t 塞给着色器,然后画一个铺满全屏的方块跑一遍着色器:

// plugins/obs-transitions/transition-fade.c:51(节选)
static void fade_callback(void *data, gs_texture_t *a, gs_texture_t *b, float t, uint32_t cx, uint32_t cy)
{
	if (a || b) {
		struct fade_info *fade = data;
		...
		gs_effect_set_texture(fade->a_param, a);      // 纹理 A → 着色器
		gs_effect_set_texture(fade->b_param, b);      // 纹理 B → 着色器
		...
		gs_effect_set_float(fade->fade_param, t);     // ★ 把进度 t 塞给着色器的 fade_val
		while (gs_effect_loop(fade->effect, tech_name))
			gs_draw_sprite(NULL, 0, cx, cy);      // 铺满全屏画一遍
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

而着色器里(data/fade_transition.effect)的混合,浓缩成一行:

// plugins/obs-transitions/data/fade_transition.effect:39(节选)
uniform float fade_val;   // 就是上面塞进来的 t
...
float4 Fade(FragData f_in)
{
	float4 a_val = tex_a.Sample(textureSampler, f_in.uv);   // 这一像素在 A 里的颜色
	float4 b_val = tex_b.Sample(textureSampler, f_in.uv);   // 在 B 里的颜色
	float4 rgba  = lerp(a_val, b_val, fade_val);            // ★ 按 t 线性插值
	return rgba;
}
1
2
3
4
5
6
7
8
9
10

lerp(a, b, t) = a * (1 - t) + b * t(线性插值,第 11 课着色器里的常客):

  • t = 0 → 全是 A(还没开始过渡);
  • t = 0.5 → A、B 各一半(画面半透明叠着);
  • t = 1 → 全是 B(过渡完成)。

每一帧 t 涨一点,这句 lerp 就让画面从 A 一点点"化"成 B。 你看到的那个丝滑淡入淡出,本质就是"逐像素、按时间比例,在 A 和 B 之间插值"这么一句。等 t 到了 1,render2 自动收尾(:670),把 B 提升成新的当前场景。滑动、擦除、缩放那些花哨转场,无非是把这句 lerp 换成别的混合公式(按位置切、按遮罩混……),骨架完全一样。


# 14.8 模块 3 真收官:一帧画面的完整前半生

📍收束:从光到交给编码器的整个前半程全打通了,这一节回头把采集→合成→转场→取帧这一路串成一条线,画面这条线正式交棒。

到这里,一帧画面从"光"到"交给编码器"的整个前半程,全部打通了:

一帧的完整前半生

  • 采集(第 10 课):摄像头/屏幕的光 → obs_source_frame;
  • 上显存(第 11 课):帧 → GPU 纹理;
  • 画 / 滤镜(第 11、12 课):着色器把源画出来、还能加特效;
  • 合成(第 13 课):多个源按矩阵和层级,叠进主纹理;
  • (转场)(第 14 课):切场景时,一个"夹在中间的 source"把两幅画面按 t 混合;
  • 按帧率取帧(第 14 课):图形线程每 1/fps 跳一次,video_output 把会抖的渲染整理成严格贴帧率的一串帧,receive_video 交到编码手里。

撑起这整条前半程的,还是那把钥匙 —— 一切皆 Source(第 6 课):采集源、滤镜、场景、转场……全是 source,都靠同一个 video_render + 纹理/着色器画出来;引擎对它们一视同仁。而 video_output 这条发布-订阅流水线,再把"会抖动的渲染"校准成"整齐的帧流",稳稳交给下一棒。

下一棒,就是"编码"——模块 5 的故事:这一串每帧几 MB 的裸画面(还记得第 5 课那个吓人的体积吗?),要被压缩成能推流、能存盘的码流。 前半程到此为止,画面这条线,交棒。


# 14.9 本课小结

  • 渲染循环 = 引擎心跳:obs_graphics_thread(obs-video.c:1163)每圈一帧 —— tick_sources 对齐时间 → output_frames 合成 → render_displays 刷预览 → video_sleep 睡到下一拍。节拍靠 os_sleepto_ns(:814)"睡到绝对时刻",interval 是帧率倒数(obs.c:692)。
  • 显存到一帧,两条路:软件编码走路 A——stage_output_texture(:406)→ download_frame/gs_stagesurface_map(:593)下载回内存 → output_video_data(:779);硬件编码走路 B——output_gpu_encoders(:519)→ queue_frame(:442)纹理直接入队喂显卡,不碰 CPU 内存。
  • video_output = 发布-订阅流水线(video-io.c:70):生产者 lock/unlock_frame(:509/:547)塞帧 + sem_post 通知;消费线程 video_thread(:190)os_sem_wait 等叮,video_output_cur_frame(:126)逐个 input->callback 发帧。编码器通过 video_output_connect2(:398)订阅,回调是 receive_video(obs-encoder.c:1478)——本质是第 8 课的发布/订阅。
  • 补帧与丢帧:video_sleep 算出的 count(:822),让同一帧在 cache 里发够 count 次(补帧,填满帧率网格);cache 满则标 skipped(丢帧,就是状态栏"跳过的帧")。把会抖的渲染,整理成严格贴帧率的帧流。
  • 转场是一个夹在中间的 source:transition_sources[2] 攥着 A、B(obs-internal.h:968);obs_transition_start(:322)点亮时钟、装入 B;和滤镜、场景是同一招"包着别的 source 的 source"。
  • 转场混合:进度 t = (now-start)/duration(calc_time,:426);obs_transition_video_render2(:653)把 A、B 各画成一张纹理,交给插件回调;fade 插件把 t 塞进着色器(transition-fade.c:83),一句 lerp(a, b, fade_val)(fade_transition.effect:43)逐像素插值,t≥1 自动收尾。
  • 模块 3 收官:采集 → 上显存 → 画/滤镜 → 合成 →(转场)→ 按帧率取帧,前半程全通,底座是"一切皆 source";下一棒交给编码(模块 5)。

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

  1. 看心跳的产物:OBS 里打开"设置 → 视频",把帧率从 60 改成 30,再改成 10。想一想:obs.c:692 那个 interval_ns 分别变成了多少纳秒?(答:16.67ms / 33.3ms / 100ms。)帧率越低,图形线程每圈之间睡得越久。
  2. 让"跳过的帧"涨起来:OBS 菜单"帮助 → 日志文件",或状态栏里找 Skipped frames / 跳过的帧。故意加一堆重滤镜、把画布分辨率拉到很高,让 GPU 忙不过来,观察这个数字上涨 —— 那就是 14.5 的 skipped_frames(video-io.c:180)在动。
  3. 慢放看 lerp:把转场时长设成 2~3 秒(设置或转场下拉里),在两个明显不同的场景间切换,盯着过渡过程 —— 你看到的"半透明叠着"那一瞬,就是 t≈0.5、着色器在算 lerp(a, b, 0.5)
  4. 翻源码验证"转场是 source":打开 plugins/obs-transitions/transition-fade.c,找到 fade_transition(:139),确认 .type = OBS_SOURCE_TYPE_TRANSITION.video_render = fade_video_render;再顺着 fade_video_render(:92)→ obs_transition_video_render2(obs-source-transition.c:653),看它怎么把 A、B 画成两张纹理。
  5. 数一数订阅链:在 libobs/obs-encoder.c 找到 add_connection(:349),看它软件路怎么调到 start_raw_video;再到 video-io.c:398video_output_connect2,确认那句 da_push_back(video->inputs, &input) —— 一个编码器"订阅"上来,就是往 inputs[] 里加一项。

# 下一课预告

画面这条线交棒了,接下来该声音登场。声音这条流水线和视频并行,却必须和视频严丝合缝地对齐(音画不同步是直播大忌)。

第 15 课:采集声音 —— 系统声音与麦克风 —— 我们进入模块 4,看 OBS 怎么从系统和麦克风抓到一段段 PCM(回顾第 4 课),它和视频采集(第 10 课)像在哪、又因为"声音是连续的、不能丢一个采样"而不同在哪。声音的旅程,从这里开始。下节课见。


📁 本课配图:imgs/14-01 ~ imgs/14-08 📌 源码锚点: libobs/obs-video.c:172(render_main_texture)、:406(stage_output_texture)、:539(render_video)、:585/:593(download_frame / gs_stagesurface_map)、:779(output_video_data)、:807/:814(video_sleep / os_sleepto_ns)、:822(count 补帧依据)、:1099(obs_graphics_thread_loop)、:1127(output_frames)、:1163(obs_graphics_thread); libobs/obs.c:692(帧率 → interval_ns)、:3101(start_raw_video)、:3182(start_gpu_encode); libobs/media-io/video-io.c:70(struct video_output)、:126(video_output_cur_frame 发帧)、:190(video_thread)、:398(video_output_connect2)、:509/:547(lock/unlock_frame); libobs/obs-encoder.c:349(add_connection)、:1478(receive_video); libobs/obs-source-transition.c:322(obs_transition_start)、:426(calc_time)、:653(obs_transition_video_render2);libobs/obs-internal.h:968(transition_sources)、libobs/obs.h:1576(A/B 枚举); plugins/obs-transitions/transition-fade.c:51/:92/:139(fade_callback / video_render / 注册)、data/fade_transition.effect:43(lerp 混合)。

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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