第 11 课:GPU 与图形子系统
# 第 11 课:GPU 与图形子系统
嘿,我是小方。 上一课结尾,采集来的那帧画面"上传成 GPU 纹理"就交给了下一站。可 GPU 到底是怎么回事?纹理是什么?着色器又是什么? 这一课我们就钻进
libobs/graphics/,把这些彻底讲明白。 这是视频链路里最硬核、也最出效果的一段。我答应你写得尽量详细,所以会从"GPU 是什么、为什么要用它"这种背景一点点铺,再上真实代码。哪怕你完全没碰过图形编程,跟下来也不慌。
# 11.1 背景:为什么"合成画面"非要用显卡
📍我们在哪:上一课那帧画面「上传成 GPU 纹理」就交了棒,这一课钻进图形子系统——先讲背景:为什么逐像素的合成活儿非交给显卡不可。
先说为什么。OBS 的合成,是把好几个图层(游戏、摄像头、文字、logo)按各自的位置、大小、透明度叠成最终一幅画面。1080p 一帧有 207 万个像素(第 2 课),每个像素都要算"这一点最终是什么颜色"。
这活儿用 CPU 干,会很惨:

- CPU 是"几个大核":强,但数量少(家用 8 核)。让它算 207 万个像素,只能几个几个地排队串着算,一帧算下来很慢。
- GPU(显卡) 是"几千个小核":每个核没 CPU 核强,但架不住多。它天生为"同一个操作、并行作用到海量数据"而生 —— 给 207 万个像素算颜色?几千个核一起上,几乎同时算完。
这类"对每个像素做同样的事"的活儿,在计算机里叫易并行(embarrassingly parallel)。画面合成、缩放、滤镜、转场,全是这种逐像素的活 —— 天生就该交给 GPU。 这就是 OBS 要有一个"图形子系统"的根本原因:把这些活儿组织起来,送到显卡上跑。
# 11.2 背景:三套显卡 API,OBS 只想写一遍
📍接上一节:知道了要用 GPU,可指挥显卡的 API 三大系统各不相同;这一节讲 OBS 怎么用一套
gs_*接口把「画一帧」只写一遍。
要指挥显卡干活,得通过显卡的编程接口(API)。麻烦在于,三大系统的接口完全不同:

- Windows → Direct3D 11(简称 D3D11)
- Linux / 跨平台 → OpenGL
- macOS → Metal
三套 API 名字、函数、概念都不一样。难道 OBS 要把"画一帧"的逻辑写三遍?那维护起来要命。
OBS 的解法,你已经在前面几课见过两次了 —— 定义一套统一接口,底下接不同实现:
- 上层所有代码,只调一套以
gs_开头的函数(gs_= graphics subsystem),比如gs_texture_create、gs_draw_sprite; - 这套
gs_*接口底下,运行时挂上对应平台的后端模块:libobs-d3d11、libobs-opengl或libobs-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_* 函数
};
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 设备
...
}
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 一遍
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, ...);
// └────────────────────────┬───────────────────────────┘
// 转发给「当前装载的后端」的实现
}
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)**走:

- 顶点(vertex) —— 你要画的形状的几个角点。画一个矩形(比如一个摄像头画面),就是 4 个角。
- 顶点着色器(Vertex Shader, VS) —— 每个顶点跑一遍,负责把每个角点摆到画面上正确的位置(用矩阵变换)。
- 光栅化(rasterize) —— GPU 自动算出"这个形状盖住了屏幕上哪些像素"。
- 像素着色器(Pixel Shader, PS) —— 每个被盖住的像素跑一遍,负责算出这个像素最终什么颜色(通常就是去纹理上采样)。
- 帧缓冲(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)。它麻雀虽小五脏俱全,我把核心摘出来,逐行注:

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);
}
}
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 队列往下走,看一帧画面怎么一路画上屏:

- 从
async_frames取出一帧 —— 第 10 课那个队列里、按时间戳该显示的那帧。 - 上传成 GPU 纹理 ——
gs_texture_create在显存开一张纹理,把这帧像素拷进去(11.4)。 - 拿默认 effect,设
image参数 —— 把刚上传的纹理,绑定到default.effect里那个uniform texture2d image上。真实代码(obs-source.c:2488):gs_effect_set_texture(param, tex); // param 是 effect 里 "image" 那个参数,tex 是这张纹理1 gs_draw_sprite(tex)—— 画一个"贴了这张纹理的矩形"(sprite,本质是两个三角形拼成的方块)。真实代码(obs-source.c:2491):gs_draw_sprite(tex, source->async_flip ? GS_FLIP_V : 0, 0, 0);1- 像素着色器
PSDrawBare逐像素采样 —— GPU 顺着管线跑:VS 把矩形四角摆好,光栅化算出覆盖的像素,然后 PS 对每个像素在纹理上采一次色。几百万像素,几千个核并行算。 - 画进画布(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_imports用os_dlsym填表 →device_create;公共gs_*全是转发给exports.device_*。 - 纹理:显存里的一张图;CPU 的帧要
gs_texture_create+ 上传才成纹理;用 0~1 的 UV 坐标 + 采样取色。 - 着色器 + 管线:顶点 → VS(每顶点,定位)→ 光栅化 → PS(每像素,算色,海量并行)→ 帧缓冲。
.effect打包 VS/PS;default.effect的PSDrawBare就一行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 动手 / 观察(本课作业)
- 看一眼真 shader:打开
libobs/data/default.effect,找到PSDrawBare,确认它真的就一行image.Sample(...)。再数数这个文件里有多少个technique(那是各种特殊画法:tonemap、alpha 处理…)。 - 在界面里改"定位矩阵":OBS 里拖动、缩放、旋转一个源 —— 你改的,本质就是喂给
VSDefault的那个ViewProj矩阵。体会"为什么定位是顶点着色器的活,而不是像素着色器"。 - 找后端:看
obs-plugins/运行目录里有没有libobs-d3d11.dll/libobs-opengl.dll(第 1 课的 rundir)。它们就是本课的"图形后端",被gs_create用os_dlopen装载的。 - (思考题):为什么屏幕采集走 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)
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言