有一个简单的问题,在OpenGL视频渲染的这一部分,都做了写什么呢?
GPU 流水线:像工厂流水线一样
现在这里有一份简单一点的流水线:
[原材料] → [切割] → [上色] → [组装] → [成品]
OpenGL的流水线与其很是相似:
顶点数据 顶点着色器 片段着色器
[三角形顶点] → [把顶点放到屏幕上] → [给每个像素上色] → 屏幕
↑ ↑ ↑
(CPU传入) shader.vert shader.frag
| 阶段 | 做什么 | 执行次数 |
|---|---|---|
| 顶点着色器 | 计算每个顶点的屏幕位置 | 每个顶点执行 1 次 |
| 片段着色器 | 计算每个像素的颜色 | 每个像素执行 1 次 |
由此可见,顶点着色器与片段着色器像是工厂流水线里的机械臂一样去运作。
对于视频渲染,我们只需要画一个矩形(4 个顶点),然后把视频画面当"贴纸"贴在上面。这就是shader.vert和shader.frag的全部工作。
什么是着色器?
着色器(Shader)更确切的说是跑在 GPU 上的小程序,用 GLSL(OpenGL Shading Language)语言编写。语法和 C 语言相似。
在Qt项目里,我们可以把它打包到 resources.qrc 里,在程序启动时加载编译:
// renderer/videorenderer.cpp 第23-27行
const QString vSrcPath = ":/shaderSource/shader.vert"; // 顶点着色器路径
const QString fSrcPath = ":/shaderSource/shader.frag"; // 片段着色器路径
if (!m_program.addCacheableShaderFromSourceFile(QOpenGLShader::Vertex, vSrcPath) ||
!m_program.addCacheableShaderFromSourceFile(QOpenGLShader::Fragment, fSrcPath) ||
!m_program.link()) {
qDebug() << "着色器程序编译失败";
return;
}
m_program 是一个 着色器程序,把顶点和片段着色器绑在一起,GPU 执行时会自动串联它们。
把形状搭起来吧
// resource/shader.vert (14行)
#version 330 core
layout(location = 0) in vec2 aPos; // 顶点坐标
layout(location = 1) in vec2 aTexCoord; // 纹理坐标
uniform mat4 transform = mat4(1.0); // 变换矩阵
out vec2 TexCoord; // 传给片段着色器
void main() {
gl_Position = transform * vec4(aPos, 0.0, 1.0);
TexCoord = aTexCoord;
}
我们现在得到了一份顶点着色器的代码。
顶点着色器,从文字意义上来看是处理顶点的机械臂。
#version 330 core是什么?
声明使用 OpenGL 3.3 核心模式。就像文件头标记"本文用 UTF-8 编码"。
layout(location = 0) in vec2 aPos;声明一个输入变量aPos,类型是vec2(两个浮点数:x 和 y)。location = 0是它的"门牌号",CPU 端通过这个号码把数据塞进来。
对应 CPU 端代码
glVertexAttribPointer(0, 2, GL_FLOAT, ...); // location=0 → aPos
layout(location = 1) in vec2 aTexCoord;同理,输入纹理坐标。纹理坐标的范围是 (0,0) 到 (1,1),(0,0) 是左下角。
CPU 端对应
glVertexAttribPointer(1, 2, GL_FLOAT, ...); // location=1 → aTexCoord
uniform mat4 transform;uniform变量是从 CPU 传进来的参数,所有顶点共用同一个值。这里是 4×4 变换矩阵,用来实现旋转、缩放、平移。
CPU 端设置这个值
m_program.setUniformValue(m_program.uniformLocation("transform"), getTransformMat());
-
out vec2 TexCoord;声明输出变量,会传给下一站——片段着色器。GPU 自动在顶点之间做插值:比如左顶点纹理坐标是 0,右顶点是 1,那中间位置的像素就得到 0.5。 -
gl_Position = transform * vec4(aPos, 0.0, 1.0);这是顶点着色器唯一必须做的事:计算顶点在屏幕上的最终位置。aPos是 2D 坐标(x, y)- 扩展成 4D
(x, y, 0.0, 1.0)(3D 图形学需要齐次坐标,这里不展开) - 乘以变换矩阵,得到最终屏幕位置
顶点数据
// videorenderer.cpp 第45-51行
float vertices[] = {
// 位置(x,y) 纹理(u,v)
-1.f, 1.f, 0.f, 1.f, // 左上角
-1.f, -1.f, 0.f, 0.f, // 左下角
1.f, -1.f, 1.f, 0.f, // 右下角
1.f, 1.f, 1.f, 1.f // 右上角
};
每行一个顶点,前两个数是屏幕位置(-1 到 1),后两个是纹理坐标(0 到 1)。
示意图:
(-1, 1) ───────── (1, 1) (0,1) ────── (1,1)
│ ↑ │ │ ↑ │
│ 纹理(0,1) │ │ 左上角 │
│ │ │ │
(-1,-1) ───────── (1,-1) (0,0) ────── (1,0)
屏幕坐标 (NDC) 纹理坐标 (UV)
位置坐标定义"画在屏幕哪里",纹理坐标定义"取贴纸的哪一块"。GPU 把它们一一对应起来。
其中的对应关系是这样:
我们可以手动处理:
在典型的全屏渲染中,纹理坐标的 [0,1] 范围会被线性拉伸并平移到 NDC 的 [-1,1] 范围,从而实现整张纹理覆盖整个屏幕。
具体数学关系:
- 屏幕坐标
(x_ndc, y_ndc)与纹理坐标(u, v)的常见映射为:或者反过来是这个映射是线性拉伸:纹理的(0,0)对应屏幕左下角(-1,-1),(1,1)对应屏幕右上角(1,1)。纹理的宽高比会被拉伸到屏幕的宽高比(如果不考虑视口设置)。
一个问题,为什么NDC坐标是 -1 到 1,而uv是[0,1] X [0,1] 呢
数学归一化:让纹理分辨率无关
假设没有归一化,而是直接使用像素坐标 (texel 坐标)。比如一张 512×512 的图片,像素位置是 (0,0) 到 (511,511)。那么:
- 对一个正方形模型来说要贴满这张图,顶点 UV 可能是 (0,0)、(512,0)、(0,512)、(512,512)。
- 如果换一张 1024×1024 的图,想要贴满同样的模型,就得手动把 UV 改成 (0,0)、(1024,0)……这非常麻烦。
在归一化到 [0,1] 之后: 无论原图是 256×256、1024×1024 还是 4K×4K,整张图永远对应 UV 矩形 (0,0)-(1,1)。更换纹理时,模型 UV 完全不需要修改。建模与材质分离 —— 美术只需要知道“这个面占满整张图”,而不用关心具体像素数。
插值友好:顶点间平滑过渡
在光栅化阶段,顶点着色器输出的 UV 会在三角形平面上进行重心坐标插值。假设你有一个三角形,三个顶点的 UV 分别是 (0,0)、(1,0)、(0,1)。那么三角形内部任意一点的 UV 计算公式是:
由于 α,β,γ 在 [0,1] 且和为 1,插值出的 u、v 自然也在 [0,1] 范围内。
如果 UV 范围是 [-1,1],插值结果会在 [-1,1] 之间,这本身没问题。但是当你把这些值直接送去纹理采样器时,采样器默认 [0,1] 区间,你需要额外做一步映射 uv = (ndc_uv + 1)/2。这浪费一个乘加指令,而且会增加着色器复杂度。
如果 UV 范围是任意值(比如 0~512),如果插值出 256.3 这样的值,那么纹理采样器需要同时处理整数部分(取哪个 tile)和小数部分(双线性插值)。虽然 GPU 也支持这种(使用 GL_REPEAT 和浮点纹理坐标),但默认的模式下,[0,1] 范围最简单直接。
另一种插值
1. 摄像机坐标系下的线性深度
在摄像机坐标系(View Space)中,一个顶点的坐标为
沿着视线方向(摄像机看向 −Z 轴)的深度通常定义为:
视线方向是沿着 −Z 轴(即摄像机看向 Z 负方向)。
中 z_e 为负时 d 为正。线性深度 d 在摄像机坐标系中随位置线性变化。
对于三角形三个顶点,深度分别为:
位置分别为:
三角形内任意一点 \mathbf{P} 的重心坐标为 (\lambda_1, \lambda_2, \lambda_3),满足:
其中,lambda_1_2_3的由来是这样的:
光栅化的任务:给定屏幕空间中的三个顶点 (X₁,Y₁), (X₂,Y₂), (X₃,Y₃),对于每个像素(片元)位置 (X,Y),判断它是否在三角形内部。
计算重心坐标:一旦确定像素在三角形内部,光栅化硬件会计算出该像素相对于三个顶点的重心坐标 (λ₁, λ₂, λ₃)。常用的方法是通过边缘方程或面积比:
- 设三角形总面积为 A_total。
- λ₁ = 子三角形 (P, B, C) 的面积 / A_total
- λ₂ = 子三角形 (A, P, C) 的面积 / A_total
- λ₃ = 子三角形 (A, B, P) 的面积 / A_total
- 这三个值满足 λ₁+λ₂+λ₃=1,且都 ≥0。
然后才用重心坐标进行插值:例如深度插值
d = λ₁*d₁ + λ₂*d₂ + λ₃*d₃,颜色、纹理坐标等也类似。
因为深度是位置的线性函数(d = -z_e),所以插值后的深度为:
这就是在摄像机坐标系中线性插值深度的数学表达式。
同一个三角形内部,不同像素(片元)计算得到的插值深度通常都不一样。
摘自稀土掘金:光栅化阶段 与 片元着色器[^ 1]
输入:顶点 输出:三角形网格 类型:管线固定部分,由管线自动完成
三角形设置 :我们从几何阶段得到了顶点,现在需要得到三角形对像素的覆盖情况,就是三角形的边界的表示形式,即把顶点连接成三角形网格,这个过程计算过程就是三角形设置。
三角形遍历
输入:三角形网格 输出:覆盖三角形网格的像素位置 类型:管线固定部分,由管线自动完成
根据输入的单个三角形网格的三个顶点,进行插值计算得到覆盖三角形网格的像素位置,对应的像素就生成一个片元。
深度(Depth)本质上是一个距离值,表示片元与摄像机之间的距离(通常经过透视投影和视口变换后归一化到
[0, 1]范围)。
输入:片元(像素)信息、顶点数据、法线等 输出:经过修改的片元(像素)信息
根据输入计算片元颜色信息,可实现纹理贴图、法线贴图等等;当前没有影响像素颜色值,仅仅只是计算。
片元操作
片元操作是片段着色器之后、帧缓冲写入之前的一系列固定功能处理阶段。
片元是经过光栅化插值、尚未通过可见性测试的“像素候选者”,它携带位置、深度、颜色等插值信息,经过片段着色器和一系列片元操作后,成功者成为屏幕上真正显示的像素。
经过计算的片元并未直接显示,因为可能存在下列情况
被物体遮挡
被半透明的物体遮挡
所以我们需要进行模版缓冲、深度缓冲、颜色混合之后才能得到最终结果,显示在帧缓存中。
光栅化总结
GPU管线渲染流程总结
回归文章
进行简单的一个小总结,有其他细的一点就没准确拎出来讲。
| 层面 | 为什么用 [0,1] 正方形? |
|---|---|
| 数学 | 归一化,让纹理分辨率无关;便于插值保持边界。 |
| 硬件 | 采样器直接乘以尺寸即可得到纹素坐标,高效。 |
| API 设计 | 所有主流图形 API 强制要求,统一标准。 |
| 复用性 | 同一个模型可换不同分辨率的纹理,UV 不用改。 |
| 平铺逻辑 | 小数部分自然表示周期,加 1 就是重复一次。 |
| 教学/可视化 | 直观,角点对应,避免负数,容易理解。 |
片段着色器
// resource/shader.frag (199行)
#version 330 core
in vec2 TexCoord; // 从顶点着色器传来的纹理坐标
// === CPU 传入的 uniform 参数 ===
uniform int pixFormat = -1; // 像素格式:0=RGB, 6=YUV, ...
uniform sampler2D yTex; // Y 纹理(或 RGB 纹理)
uniform sampler2D uTex; // U 纹理
uniform sampler2D vTex; // V 纹理
uniform sampler2D aTex; // A(透明度) 纹理
uniform sampler2D subTex; // 字幕纹理
uniform int colorSpace = 1; // 色彩空间:0=BT.601, 1=BT.709, 2=BT.2020
uniform bool fullRange = false; // YUV 范围:false=电视, true=电脑
out vec4 FragColor; // 最终输出:该像素的颜色
void main() {
// 第1步:根据像素格式采样纹理,得到原始颜色
// 第2步:YUV→RGB 转换(如果需要)
// 第3步:亮度/对比度/饱和度调整
// 第4步:混合字幕
// 第5步:输出最终颜色
}
这是项目里的一个片段着色器,它涉及到上文片元的处理。
第1步:采样纹理:不同格式不同处理
视频解码出来的数据有多种格式,着色器用 pixFormat 区分:
// shader.frag 第104-117行(简化) 其实在main里
if (pixFormat == 0) {
// RGB_PACKED: 三个颜色通道打包在一起,直接取
color = vec4(texture(yTex, TexCoord).rgb, 1.0);
} else if (pixFormat == 6 || pixFormat == 7) {
// YUV / YUVA: 亮度和颜色分开存储,分别从不同纹理取
float y = texture(yTex, TexCoord).r; // 亮度从纹理0取
float u = texture(uTex, TexCoord).r; // 蓝色分量从纹理1取
float v = texture(vTex, TexCoord).r; // 红色分量从纹理2取
// ... 然后转成 RGB
}
为什么 YUV 要分开存? 这是视频编码的一个经典技巧:人眼对亮度敏感、对颜色不敏感。所以编码时把亮度(Y)保留完整分辨率,颜色(U、V)可以缩小到一半甚至四分之一,大幅节省带宽。播放时需要把它们重新合成 RGB。
texture(纹理, 坐标)是 GLSL 内置函数,相当于"从这张贴纸上,在指定坐标处取一个颜色"。
第2步:YUV → RGB 转换:颜色空间的"翻译官"
YUV 和 RGB 是两种描述颜色的"语言",着色器里的转换函数就是翻译:
// shader.frag 第33-39行 — BT.709 转换矩阵
vec3 yuv2rgb709(float y, float u, float v) {
y = y * 1.1643835616;
return vec3(
y + 1.7927410714 * v - 0.9729450750, // 红色通道
y - 0.2132486143 * u - 0.5329093286 * v + 0.3014826655, // 绿色通道
y + 2.1124017857 * u - 1.1334022179 // 蓝色通道
);
}
1.7927410714 是啥? 这数字好奇怪
其实它是YUV → RGB变换的一个具体操作数值,是一个常量。
为什么有三种转换矩阵(BT.601/709/2020)?
| 标准 | 适用场景 | 色域大小 |
|---|---|---|
| BT.601 | 标清视频 (SD) | 小 |
| BT.709 | 高清视频 (HD/1080p) | 中 |
| BT.2020 | 超高清 (4K HDR) | 大 |
代码从 FFmpeg 解码出的 AVFrame.color_space 字段判断用哪个:
// videorenderer.cpp 第260-268行
if (frm->color_space == AVCOL_SPC_BT709) {
currentColorSpace = 1; // 着色器里用 BT.709
} else if (frm->color_space == AVCOL_SPC_BT2020_NCL) {
currentColorSpace = 2; // 着色器里用 BT.2020
} else {
currentColorSpace = 0; // 默认 BT.601
}
一次渲染的完整流程
注意:此部分为Ai整理
render() 被调用(每帧一次)
│
├─ ① 准备视频纹理
│ glActiveTexture(GL_TEXTURE0 + 0); // 选中纹理单元0
│ glBindTexture(GL_TEXTURE_2D, m_texArr[0]); // 绑定Y纹理
│ glTexSubImage2D(...); // 上传解码后的Y数据
│ // 对 U、V 纹理重复(纹理单元1、2)
│
├─ ② 绑定着色器程序
│ m_program.bind(); // 激活着色器
│
├─ ③ 设置 uniform 参数(CPU→GPU通信)
│ m_program.setUniformValue("colorSpace", 1); // BT.709
│ m_program.setUniformValue("fullRange", false); // 电视范围
│ m_program.setUniformValue("transform", matrix); // 变换矩阵
│
├─ ④ 绘制
│ glBindVertexArray(m_vao); // 绑定矩形顶点数据
│ glDrawElements(GL_TRIANGLES, 6, ...); // 画两个三角形=一个矩形
│
└─ ⑤ 解绑
m_program.release();
OpenGL的处理
m_program.bind();
// 告诉着色器:yTex 去纹理单元 0 取数据
m_program.setUniformValue(m_program.uniformLocation("yTex"), 0);
// 告诉着色器:uTex 去纹理单元 1 取数据
m_program.setUniformValue(m_program.uniformLocation("uTex"), 1);
// 告诉着色器:vTex 去纹理单元 2 取数据
m_program.setUniformValue(m_program.uniformLocation("vTex"), 2);
// 告诉着色器:aTex 去纹理单元 3 取数据
m_program.setUniformValue(m_program.uniformLocation("aTex"), 3);
着色器里写 uniform sampler2D yTex;,GPU 不知道它对应哪张纹理。CPU 通过 setUniformValue 告诉它"yTex 在 0 号槽位"。这就像告诉快递员"你的包裹在 3 号快递柜"。
渲染前再逐一激活每个单元并绑定纹理:
// videorenderer.cpp 第518-541行
void VideoRenderer::bindAllTexturesForDraw() {
for (int i = 0; i < 4; ++i) {
glActiveTexture(GL_TEXTURE0 + i); // 切到第 i 个槽位
if (m_texArr[i] != 0) {
glBindTexture(GL_TEXTURE_2D, m_texArr[i]); // 放纹理进去
} else {
glBindTexture(GL_TEXTURE_2D, 0); // 空的就解绑,防止误采样
}
}
....
}
纹理的创建
// videorenderer.cpp 第466-481行
void VideoRenderer::initTex(GLuint &tex, const QSize &size,
const std::array<unsigned int, 3> ¶,
uint8_t *fill) {
if (tex != 0)
glDeleteTextures(1, &tex); // 先删旧的
glGenTextures(1, &tex); // 生成新纹理ID
glBindTexture(GL_TEXTURE_2D, tex); // 绑定
// 设置采样参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); // 不重复
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // 缩小时线性插值
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 放大时线性插值
// 分配 GPU 内存并上传数据
glTexImage2D(GL_TEXTURE_2D, 0, para[0], // 内部格式(如 GL_R8)
size.width(), size.height(), 0,
para[1], para[2], // 外部格式(如 GL_RED) + 数据类型
fill); // 实际数据(可为空)
}
变换矩阵的构造
视频需要支持旋转、缩放、平移、镜像。CPU 端把所有这些操作合成一个 4×4 矩阵,一次性传给着色器:
// videorenderer.cpp 第483-516行
QMatrix4x4 VideoRenderer::getTransformMat() {
QMatrix4x4 mat;
mat.setToIdentity(); // 初始化为单位矩阵
mat.translate(m_tx, m_ty, 0.0f); // ⑤ 平移
// ④ 画面比例修正
float scaleX = ..., scaleY = ...;
mat.scale(scaleX / scaleX0, scaleY / scaleY0, 1.0f);
mat.scale(m_scaleX, m_scaleY, 1.0f); // ③ 缩放+镜像
mat.rotate(m_angle, 0, 0, 1); // ② 旋转
mat.scale(scaleX0, scaleY0, 1.f); // ① 原始比例
return mat;
}
注意矩阵乘法的顺序:代码里先 scale 再 rotate 再 translate,但实际执行顺序是反过来的——因为矩阵乘法不满足交换律。OpenGL 的惯例是右乘:
最终位置 = translate × scale × rotate × scale × 原始位置,所以最右边的操作最先生效。
其中的具体变换
下面将 getTransformMat() 中的变换过程用 LaTeX 表示。
首先明确变换顺序(代码从上到下执行,每个变换左乘当前矩阵,因此最后的变换最先作用于顶点):
- 原始比例
scaleX0, scaleY0(最后一行,最先应用) - 旋转
m_angle(绕 Z 轴) - 缩放+镜像
m_scaleX, m_scaleY - 画面比例修正
scaleX / scaleX0, scaleY / scaleY0 - 平移
(m_tx, m_ty)(第一行,最后应用)
对应的顶点变换为:
各变换的 4×4 矩阵
设原始点齐次坐标 p**=(x,y,0,1)⊤。
① 原始比例
② 旋转 (绕 Z 轴)
③ 缩放 + 镜像
④ 画面比例修正
⑤ 平移
最终矩阵为:
展开(从左往右)
简化后的结果
前三部分(比例+镜像+旋转+原始比例)可先合并为:
最后左乘平移矩阵:
这就是最终作用于顶点 (x,y,0,1)⊤(x,y,0,1)⊤ 的变换矩阵。
整体流程
┌─────────────────────────────────────────────────────────┐
│ CPU 端 │
│ │
│ ① FFmpeg 解码 │
│ mp4 文件 → avcodec_decode → AVFrame (YUV420p 裸数据) │
│ │
│ ② 提取元数据 │
│ frm->width, frm->height → m_frameSize │
│ frm->color_space → currentColorSpace (BT.709/2020) │
│ frm->format → pixFormat (6=YUV, 0=RGB...) │
│ │
│ ③ 上传到 GPU 纹理 │
│ glTexSubImage2D → Y plane 存入纹理单元0 │
│ glTexSubImage2D → U plane 存入纹理单元1 │
│ glTexSubImage2D → V plane 存入纹理单元2 │
│ │
│ ④ 设置着色器参数 │
│ uniform pixFormat = 6 (YUV格式) │
│ uniform colorSpace = 1 (BT.709) │
│ uniform fullRange = false │
│ uniform transform = 旋转/缩放/平移矩阵 │
│ │
│ ⑤ 触发绘制 │
│ glDrawElements() → GPU 开始工作 │
└────────────────────┬────────────────────────────────────┘
│
┌────────────────────▼────────────────────────────────────┐
│ GPU 端 │
│ │
│ ⑥ 顶点着色器 (shader.vert) × 4次 │
│ 每个顶点: pos = transform × 原始位置 │
│ 输出: 4个屏幕位置 + 插值用的纹理坐标 │
│ │
│ ⑦ 光栅化 (GPU 硬件自动完成) │
│ 把矩形切成一个个像素 │
│ 纹理坐标在顶点间线性插值 │
│ │
│ ⑧ 片段着色器 (shader.frag) × 1920×1080次 │
│ 每个像素: │
│ y = texture(yTex, uv).r ← 从纹理0取亮度 │
│ u = texture(uTex, uv).r ← 从纹理1取色度 │
│ v = texture(vTex, uv).r ← 从纹理2取色度 │
│ rgb = yuv2rgb709(y, u, v) ← BT.709矩阵转换 │
│ rgb = (rgb-0.5)*contrast+0.5 ← 对比度 │
│ 叠加字幕 (如有) │
│ FragColor = rgb ← 写入帧缓冲 │
└────────────────────┬────────────────────────────────────┘
│
┌────────────────────▼────────────────────────────────────┐
│ 屏幕 │
│ FBO → Qt Quick → 显示器 │
└─────────────────────────────────────────────────────────┘
总结
| 组件 | 文件 | 做什么 |
|---|---|---|
| 顶点着色器 | resource/shader.vert |
把矩形顶点摆到屏幕正确位置 |
| 片段着色器 | resource/shader.frag |
每个像素:采样→YUV转RGB→调色→叠字幕 |
| 渲染器 C++ | renderer/videorenderer.cpp |
管理纹理生命周期、上传帧数据、设置 uniform |
| 渲染器头文件 | renderer/videorenderer.h |
声明类接口和 OpenGL 资源 |
| 资源注册 | resources.qrc |
把 .vert/.frag 打包进可执行文件 |
- CPU 管粗活:解码、创建纹理、设置参数
- GPU 管细活:逐像素的颜色计算(200万个像素同时算)
- 着色器是翻译官:把 YUV"翻译"成 RGB,把 BT.601 色彩空间"翻译"成屏幕能显示的 sRGB
- 纹理单元是快递柜:yTex→0号柜,uTex→1号柜,片段着色器按柜号取数据
[^ 1]: GPU渲染管线流程GPU渲染管线 几何阶段 光栅化阶段 顶点着色器 片元着色器 模型变换 视图变换 曲面细分 剪裁 三角 - 掘金