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

第 11 课:GPU 与图形子系统

# 第 11 课:GPU 与图形子系统

嘿,我是小方。 上一课结尾,采集来的那帧画面"上传成 GPU 纹理"就交给了下一站。可 GPU 到底是怎么回事?纹理是什么?着色器又是什么? 这一课我们就钻进 libobs/graphics/,把这些彻底讲明白。 这是视频链路里最硬核、也最出效果的一段。我答应你写得尽量详细,所以会从"GPU 是什么、为什么要用它"这种背景一点点铺,再上真实代码。哪怕你完全没碰过图形编程,跟下来也不慌。


# 11.1 背景:为什么"合成画面"非要用显卡

📍我们在哪:上一课那帧画面「上传成 GPU 纹理」就交了棒,这一课钻进图形子系统——先讲背景:为什么逐像素的合成活儿非交给显卡不可。

先说为什么。OBS 的合成,是把好几个图层(游戏、摄像头、文字、logo)按各自的位置、大小、透明度成最终一幅画面。1080p 一帧有 207 万个像素(第 2 课),每个像素都要算"这一点最终是什么颜色"。

这活儿用 CPU 干,会很惨:

CPU vs GPU

  • CPU 是"几个大核":强,但数量少(家用 8 核)。让它算 207 万个像素,只能几个几个地排队串着算,一帧算下来很慢。
  • GPU(显卡) 是"几千个小核":每个核没 CPU 核强,但架不住多。它天生为"同一个操作、并行作用到海量数据"而生 —— 给 207 万个像素算颜色?几千个核一起上,几乎同时算完。

这类"对每个像素做同样的事"的活儿,在计算机里叫易并行(embarrassingly parallel)。画面合成、缩放、滤镜、转场,全是这种逐像素的活 —— 天生就该交给 GPU。 这就是 OBS 要有一个"图形子系统"的根本原因:把这些活儿组织起来,送到显卡上跑。


# 11.2 背景:三套显卡 API,OBS 只想写一遍

📍接上一节:知道了要用 GPU,可指挥显卡的 API 三大系统各不相同;这一节讲 OBS 怎么用一套 gs_* 接口把「画一帧」只写一遍。

要指挥显卡干活,得通过显卡的编程接口(API)。麻烦在于,三大系统的接口完全不同:

三套 API,一套代码

  • WindowsDirect3D 11(简称 D3D11)
  • Linux / 跨平台OpenGL
  • macOSMetal

三套 API 名字、函数、概念都不一样。难道 OBS 要把"画一帧"的逻辑写三遍?那维护起来要命。

OBS 的解法,你已经在前面几课见过两次了 —— 定义一套统一接口,底下接不同实现:

  • 上层所有代码,只调一套以 gs_ 开头的函数(gs_ = graphics subsystem),比如 gs_texture_creategs_draw_sprite;
  • 这套 gs_* 接口底下,运行时挂上对应平台的后端模块:libobs-d3d11libobs-opengllibobs-metal(还记得第 1 课目录里这几个吗?)。

一套 gs_* 代码,三种显卡通吃。 那这个"统一接口 + 后端"在代码里长什么样?


# 11.3 源码:后端怎么接进来 —— 又是 vtable + 动态加载

📍为什么现在讲这个:「统一接口 + 后端」在代码里长什么样?这一节你会发现——又是第 6/9 课那套 vtable + 动态加载,一模一样。

如果你学完了第 6 课(obs_source_info)和第 9 课(插件加载),这一节会有强烈的既视感 —— OBS 又用了同一个套路。

设备函数表 + 后端加载

# 统一接口的真身:一张"设备函数表"

那套 gs_* 接口,底层对应一张巨大的函数指针表,叫 struct gs_exports(graphics-internal.h:30)。每个后端(D3D11/OpenGL/Metal)都要填一份:

// libobs/graphics/graphics-internal.h:30 (节选)
struct gs_exports {
	int  (*device_create)(gs_device_t **device, uint32_t adapter);      // 建一个 GPU 设备
	gs_texture_t *(*device_texture_create)(...);                        // 建一张纹理
	gs_shader_t  *(*device_pixelshader_create)(...);                    // 编译一个像素着色器
	void (*device_load_texture)(gs_device_t *, gs_texture_t *, int);    // 绑定纹理,准备画
	void (*device_set_render_target)(...);                              // 设定「画到哪」
	void (*device_draw)(...);                                           // 真正下达绘制命令
	...  // 上百个 device_* 函数
};
1
2
3
4
5
6
7
8
9
10

是不是和第 6 课那张 obs_source_info(一堆函数指针的"登记表")一个味儿?这就是 C 语言手搓的虚函数表(vtable) —— D3D11 后端把这些 device_* 指针填成自己的 D3D 实现,OpenGL 后端填成自己的 GL 实现。

# 装载后端:gs_create,还是 os_dlopen + os_dlsym

后端怎么被装进来、这张表怎么被填好的?看 gs_create(graphics.c:198),你会笑出来 —— 跟第 9 课加载插件一字不差:

// libobs/graphics/graphics.c:198 (抽骨架)
int gs_create(graphics_t **pgraphics, const char *module, uint32_t adapter)
{
	graphics_t *graphics = bzalloc(sizeof(struct graphics_subsystem));

	graphics->module = os_dlopen(module);                  // ① 把后端 .dll 装进进程(第 9 课那个 os_dlopen!)
	if (!graphics->module) { ...; goto error; }

	if (!load_graphics_imports(&graphics->exports,         // ② 填好 vtable —— 见下
	                           graphics->module, module))
		goto error;

	errcode = graphics->exports.device_create(&graphics->device, adapter);  // ③ 通过表,真正建 GPU 设备
	...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

而第②步的 load_graphics_imports,核心就一个宏(graphics-imports.c:40):

#define GRAPHICS_IMPORT(func)                            \
	exports->func = os_dlsym(module, #func);   /* 按函数名,从后端 dll 里取地址 */ \
	if (!exports->func) success = false;       /* 找不到必填项 → 失败 */
...
GRAPHICS_IMPORT(device_create);
GRAPHICS_IMPORT(device_texture_create);
GRAPHICS_IMPORT(device_pixelshader_create);
...  // 把每个 device_* 都 os_dlsym 一遍
1
2
3
4
5
6
7
8

os_dlsym!正是第 9 课那个"按函数名从 dll 里揪地址"的家伙(Windows 的 GetProcAddress / Linux 的 dlsym)。后端模块,本质就是一个导出了一堆 device_* 函数的特殊插件。

# 之后:公共 API 全是"转发"

装好之后,你调的每个 gs_* 函数,内部都只是转发给那张表。比如 gs_texture_create(graphics.c:1384):

gs_texture_t *gs_texture_create(uint32_t width, uint32_t height, ...)
{
	graphics_t *graphics = thread_graphics;
	...
	return graphics->exports.device_texture_create(graphics->device, width, height, ...);
	//     └────────────────────────┬───────────────────────────┘
	//                  转发给「当前装载的后端」的实现
}
1
2
3
4
5
6
7
8

一条主线串起三课:obs_source(第 6 课)、插件(第 9 课)、图形后端(本课)—— 全是"一张函数指针表 + 运行时动态加载"。 OBS 的可扩展性,就建立在这一个朴素而强大的模式上。

架构讲透了,接下来认识三块基本积木:纹理、着色器、绘制


# 11.4 积木一:纹理(texture)—— 显存里的一张图

📍转折:架构讲透了,接下来认三块基本积木;第一块是纹理——住在显存里、供 GPU 读取的一张图。

要让 GPU 处理一张图,这张图得先住进显卡自己的内存(显存 / VRAM)。住在显存里、供 GPU 读取的图,就叫纹理

什么是纹理

三个要点,记牢了后面全用得上:

  • 纹理 = 显存里的像素网格。 你 CPU 内存里的那帧画面(第 10 课的 obs_source_frame),GPU 是碰不到的 —— 得先用 gs_texture_create(width, height, format, ...) 在显存里开一张纹理,再把像素上传进去。一旦成了纹理,GPU 就能飞快地读它。
  • UV 坐标。 取纹理上某一点,不用像素坐标(那样换个分辨率就乱了),而用 0~1 的比例坐标:(0,0) 是左上角,(1,1) 是右下角。这套"归一化"坐标,对任何尺寸的纹理都通用。
  • 采样(sample)。 给一个 UV,从纹理里取出那一点的颜色,叫采样。放大时取到两个像素之间?GPU 会做双线性插值,平滑过渡,不会一格一格地糊。

所以第 10 课那帧画面,进入渲染线程后第一件事,就是被 gs_texture_create + 上传,变成这样一张纹理。从此它就活在显卡上了。


# 11.5 积木二:着色器与渲染管线

📍接上一节:有了纹理这块「原料」,这一节讲 GPU 怎么「加工」它——着色器,以及数据流经的那条渲染管线。

有了纹理,怎么"画"出来?这就要讲 GPU 干活的方式了。

着色器(shader),就是一段跑在 GPU 上的小程序。 你写好它,GPU 会对海量数据各跑一遍。GPU 画一个东西,数据要顺着一条**渲染管线(pipeline)**走:

渲染管线

  1. 顶点(vertex) —— 你要画的形状的几个角点。画一个矩形(比如一个摄像头画面),就是 4 个角。
  2. 顶点着色器(Vertex Shader, VS) —— 每个顶点跑一遍,负责把每个角点摆到画面上正确的位置(用矩阵变换)。
  3. 光栅化(rasterize) —— GPU 自动算出"这个形状盖住了屏幕上哪些像素"。
  4. 像素着色器(Pixel Shader, PS) —— 每个被盖住的像素跑一遍,负责算出这个像素最终什么颜色(通常就是去纹理上采样)。
  5. 帧缓冲(framebuffer) —— 算出的颜色被写进目标画布。

注意对比 VS 和 PS 的"跑的次数":VS 跑 4 遍(4 个角),PS 要跑几百万遍(每个像素一遍)。PS 这种海量重复,正是 11.1 说的 GPU 几千个核大显身手的地方。

那"VS 是哪段、PS 是哪段、怎么画"写在哪?OBS 把它们打包进一个 .effect 文件。下面拆一个真的给你看。


# 11.6 拆一个真的着色器:default.effect

📍为什么现在讲这个:管线是抽象的,这一节拆一个 OBS 真在用的着色器 default.effect,把 VS/PS 那几步落到真代码。

OBS 画任何一个源,默认就用这个着色器(libobs/data/default.effect)。它麻雀虽小五脏俱全,我把核心摘出来,逐行注:

default.effect 剖析

uniform float4x4 ViewProj;   // 「定位矩阵」:把这个源摆到画布的哪个位置、多大
uniform texture2d image;     // 要画的纹理 —— 就是那帧画面

// 顶点着色器:每个角点跑一遍
VertInOut VSDefault(VertInOut v) {
	v.pos = mul(v.pos, ViewProj);   // 角点坐标 × 矩阵 → 摆到画布上的正确位置
	v.uv  = v.uv;                   // 纹理坐标原样带下去
	return v;
}

// 像素着色器:每个像素跑一遍
float4 PSDrawBare(VertInOut v) : TARGET {
	return image.Sample(def_sampler, v.uv);   // 在纹理上按 uv 采样,返回这一点的颜色
}

// 技法(technique):把 VS + PS 打包成「一招」
technique Draw {
	pass {
		vertex_shader = VSDefault(vert_in);
		pixel_shader  = PSDrawBare(vert_in);
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

把它和 11.5 那条管线对上,全通了:

  • uniform 是着色器的输入参数(由 CPU 那边喂进来):image 是要画的纹理,ViewProj 是把源摆到画布何处的矩阵。
  • VSDefault 就是管线第②步:mul(v.pos, ViewProj) 把矩形的每个角乘以矩阵,挪到画布上正确的位置/大小。这也是为什么你能在 OBS 里随意拖动、缩放一个源 —— 改的就是这个矩阵。
  • PSDrawBare 就是管线第④步,短到只有一行:对每个像素,在纹理 image 上按它的 uv 采一下色,返回。207 万个像素,就靠 GPU 把这一行并行跑 207 万遍。
  • technique Draw 把这对 VS/PS 绑成一招叫 "Draw"。OBS 画一个普通的源,用的就是 "Draw" 这一招。

.effect 是 OBS 自己设计的一种着色器语言。 编译时,它会被翻译成各后端的原生着色器语言:Direct3D 的 HLSL、OpenGL 的 GLSL、Metal 的 MSL。也就是说,你写一份 .effect,三种显卡后端通用 —— 第 11.2 节那个"统一接口",一路贯彻到了着色器这一层。第 12 课讲滤镜时,你自己就会写 .effect


# 11.7 把一切连起来:一帧画面的 GPU 旅程

📍收束:背景和三块积木都备齐了,这一节接着第 10 课那个 async_frames 队列往下走,把一帧画面上屏的 GPU 旅程连起来。

背景和积木都备齐了。现在接着第 10 课那个 async_frames 队列往下走,看一帧画面怎么一路画上屏:

一帧画面的 GPU 旅程

  1. async_frames 取出一帧 —— 第 10 课那个队列里、按时间戳该显示的那帧。
  2. 上传成 GPU 纹理 —— gs_texture_create 在显存开一张纹理,把这帧像素拷进去(11.4)。
  3. 拿默认 effect,设 image 参数 —— 把刚上传的纹理,绑定到 default.effect 里那个 uniform texture2d image 上。真实代码(obs-source.c:2488):
    gs_effect_set_texture(param, tex);   // param 是 effect 里 "image" 那个参数,tex 是这张纹理
    
    1
  4. gs_draw_sprite(tex) —— 画一个"贴了这张纹理的矩形"(sprite,本质是两个三角形拼成的方块)。真实代码(obs-source.c:2491):
    gs_draw_sprite(tex, source->async_flip ? GS_FLIP_V : 0, 0, 0);
    
    1
  5. 像素着色器 PSDrawBare 逐像素采样 —— GPU 顺着管线跑:VS 把矩形四角摆好,光栅化算出覆盖的像素,然后 PS 对每个像素在纹理上采一次色。几百万像素,几千个核并行算。
  6. 画进画布(render target) —— 算出的颜色写进当前的"画布"(渲染目标)。这个源就画好了;别的源各自这么画一遍,叠在一起,就是合成出的最终一幅画面。

走完这一趟,第 10 课那帧"内存里的像素",就变成了"屏幕上你看到的画面"。采集(第 10 课)→ 上显存成纹理 → 着色器画进画布(本课),视频链路的前半段,闭环了。


# 11.8 回到两条采集路:屏幕采集为什么直接走 GPU

📍收束:GPU 流程走通后,正好回扣第 6/10 课留的尾巴——屏幕采集为什么能不走内存、直接把显存当纹理用。

学到这,第 6 课、第 10 课留的那个尾巴也能解开了。还记得"两条出帧路"吗?

  • 摄像头(第 10 课)走异步路:画面在 CPU 内存里产生,得 gs_texture_create 上传到显存,才能画。多一次"内存 → 显存"的搬运。
  • 屏幕采集 / 游戏采集 走 GPU 路(video_render 回调):它们要的画面本来就是显卡渲染出来的、本来就在显存里!何必费劲拷到内存再拷回去?于是它们直接把那块显存当纹理用,省掉两次搬运,自然更快、更省。

所以你看,所谓"两条路"的本质区别,就是 画面源头在内存、还是在显存。但殊途同归:最后都变成一张纹理,走 11.7 那套着色器流程,画进画布。图形子系统,是它们共同的下游。


# 11.9 本课小结

  • 为什么用 GPU:合成是逐像素的"易并行"活儿;CPU 几个大核串着算慢,GPU 几千小核并行快几十倍。
  • 三套 API 难题:Windows(D3D11)/Linux(OpenGL)/macOS(Metal)接口全不同;OBS 用统一的 gs_* 接口 + 运行时挑一个后端。
  • 后端 = vtable + 动态加载(和第 6/9 课同一套路):struct gs_exports(graphics-internal.h:30)是设备函数表;gs_create(graphics.c:198)os_dlopen 后端 dll → load_graphics_importsos_dlsym 填表 → device_create;公共 gs_* 全是转发给 exports.device_*
  • 纹理:显存里的一张图;CPU 的帧要 gs_texture_create + 上传才成纹理;用 0~1 的 UV 坐标 + 采样取色。
  • 着色器 + 管线:顶点 → VS(每顶点,定位)→ 光栅化 → PS(每像素,算色,海量并行)→ 帧缓冲。.effect 打包 VS/PS;default.effectPSDrawBare 就一行 image.Sample.effect 编译时翻成 HLSL/GLSL/MSL,一份通用。
  • 一帧的 GPU 旅程:async_frames 取帧 → 上传成纹理 → gs_effect_set_texture 设 image → gs_draw_sprite 画矩形 → PS 逐像素采样 → 画进画布(obs-source.c:2488/2491)。
  • 两条采集路本质差别:源头在内存(摄像头,要上传)还是在显存(屏幕采集,直接当纹理);最终都汇成纹理走同一套渲染。

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

  1. 看一眼真 shader:打开 libobs/data/default.effect,找到 PSDrawBare,确认它真的就一行 image.Sample(...)。再数数这个文件里有多少个 technique(那是各种特殊画法:tonemap、alpha 处理…)。
  2. 在界面里改"定位矩阵":OBS 里拖动、缩放、旋转一个源 —— 你改的,本质就是喂给 VSDefault 的那个 ViewProj 矩阵。体会"为什么定位是顶点着色器的活,而不是像素着色器"。
  3. 找后端:看 obs-plugins/运行目录里有没有 libobs-d3d11.dll / libobs-opengl.dll(第 1 课的 rundir)。它们就是本课的"图形后端",被 gs_createos_dlopen 装载的。
  4. (思考题):为什么屏幕采集走 GPU 路能省掉"两次搬运"?画面源头在哪,决定了走哪条路?(提示:屏幕内容是谁渲染出来的、本来在哪块内存?)

# 下一课预告

这一课我们学会了"用一个着色器,把一张纹理原样画出来"。可如果我想在画出来之前,先对它动点手脚呢 —— 比如把绿色背景抠掉(色键)、把画面模糊一下、调个色?

第 12 课:给画面加特效 —— 滤镜与着色器 —— 滤镜(第 6 课说过,它也是个 source!)的本质,就是在画之前插一段你自己的像素着色器。我会带你写一个最简单的 .effect,亲手做一个滤镜出来 —— 这一课的 PSDrawBare 只会"原样采样",下一课我们让它"采样之后再改一改"。视频链路最好玩的一段,来了。下节课见。


📁 本课配图:imgs/11-01 ~ imgs/11-07 📌 源码锚点:libobs/graphics/graphics-internal.h:30(gs_exports vtable)、libobs/graphics/graphics-imports.c:40(load_graphics_imports / os_dlsym)、libobs/graphics/graphics.c:198(gs_create / os_dlopen)、:1384(gs_texture_create 转发)、libobs/graphics/graphics.h:405/413/432/584(effect/draw API)、libobs/data/default.effect(默认着色器)、libobs/obs-source.c:2488(gs_effect_set_texture)、:2491(gs_draw_sprite)

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

社区交流

讨论与留言

前往 GitHub Issues →

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

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