第 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)) // ← 一圈 = 一帧,直到退出
;
}
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();
}
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 后面要用(补帧)
...
}
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);
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;
}
2
3
4
5
6
7
8
9
10
11
画完,render_video(obs-video.c:539)接着把这张纹理缩放、转成 YUV(编码器爱吃 YUV,回顾第 3 课),然后分叉成两条路:
# 路 A:下载回内存(软件编码走这条)
软件编码器(x264,在 CPU 上跑)需要的是内存里的像素,所以要把画面从显存"下载"回来:
stage_output_texture(obs-video.c:406) —— 发起一次 GPU→**暂存面(staging surface)**的拷贝(gs_stage_texture)。暂存面是一块"CPU 能读到"的显存,专门用来往回倒数据。download_frame(obs-video.c:585) —— 下一帧的时候,把暂存面映射进普通内存:// libobs/obs-video.c:593 if (!gs_stagesurface_map(surface, &frame->data[channel], &frame->linesize[channel]))1
2gs_stagesurface_map之后,frame->data就是一个你能用 CPU 直接读的像素指针了。(为什么隔一帧才读?因为 GPU→CPU 的回读有延迟,当帧就读会卡住等 GPU;错开一帧,等它悄悄传完再取,不阻塞。)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
13count参数——正是 14.2 里video_sleep算出来的"落后了几帧"。它一路被带到这里。
# 路 B:纹理直接喂显卡(硬件编码走这条)
硬件编码器(NVENC、QSV、AMF——显卡里专门的编码电路)能直接吃显存里的纹理,那还下载回内存干嘛?多此一举,还慢。于是这条路全程不碰 CPU 内存:
output_gpu_encoders(obs-video.c:519) —— 把"共享纹理"交给 GPU 编码路径。queue_frame(obs-video.c:442) —— 把共享纹理入队到gpu_encoder_queue。os_sem_post(obs-video.c:504) —— "叮"一下(信号量 = 一个能"叫醒等待方"的计数器信号),唤醒专门的 GPU 编码线程来取。
// libobs/obs-video.c:504
os_sem_post(video->gpu_encode_semaphore);
2
一句话对比:软件编码要"把画搬回内存"(路 A),硬件编码"让显卡就地把纹理编掉"(路 B)。 这也解释了为什么开硬件编码 CPU 占用低——画面根本没往 CPU 那边搬。
# 14.4 video_output:一条带"订阅"的流水线
📍我们在哪:上一节软件那条路把帧塞进了一个叫
video_output的东西,这一节拆开它——一条生产者塞帧、编码器订阅收帧的流水线。
上一节路 A 末尾那个 video_output_lock_frame / unlock_frame,把帧塞进了一个叫 video_output(类型 video_t) 的东西。它是一条生产者/消费者流水线:渲染线程当生产者往里塞帧,编码器当消费者订阅、收帧。

这个结构本身(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]; // ★ 帧的环形缓冲
};
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);
}
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;
}
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 次才算完
...
}
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_start → add_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); // 软件:订阅管线
}
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 课的场景(包着一串源的源)是同一招。

平时,引擎的输出通道 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 各自的离屏纹理
2
3
4
5
6
而这个 [0] / [1] 的下标,就是那个一眼能看懂的枚举(obs.h:1576):
enum obs_transition_target {
OBS_TRANSITION_SOURCE_A, // = 0
OBS_TRANSITION_SOURCE_B, // = 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
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
}
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
...
}
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_child → obs_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 交进去
}
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); // 铺满全屏画一遍
}
}
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;
}
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 动手 / 观察(本课作业)
- 看心跳的产物:OBS 里打开"设置 → 视频",把帧率从 60 改成 30,再改成 10。想一想:
obs.c:692那个interval_ns分别变成了多少纳秒?(答:16.67ms / 33.3ms / 100ms。)帧率越低,图形线程每圈之间睡得越久。 - 让"跳过的帧"涨起来:OBS 菜单"帮助 → 日志文件",或状态栏里找 Skipped frames / 跳过的帧。故意加一堆重滤镜、把画布分辨率拉到很高,让 GPU 忙不过来,观察这个数字上涨 —— 那就是 14.5 的
skipped_frames(video-io.c:180)在动。 - 慢放看
lerp:把转场时长设成 2~3 秒(设置或转场下拉里),在两个明显不同的场景间切换,盯着过渡过程 —— 你看到的"半透明叠着"那一瞬,就是t≈0.5、着色器在算lerp(a, b, 0.5)。 - 翻源码验证"转场是 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 画成两张纹理。 - 数一数订阅链:在
libobs/obs-encoder.c找到add_connection(:349),看它软件路怎么调到start_raw_video;再到video-io.c:398的video_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 混合)。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言