泠弦月屿 Rinzemoon
← Back to the beginning

OpenGL着色器设计

2026-06-08 文章 泠時月 12 分钟 4075 字
OpenGLcpp
文件路径: content/posts/gl.md

有一个简单的问题,在OpenGL视频渲染的这一部分,都做了写什么呢?

GPU 流水线:像工厂流水线一样

现在这里有一份简单一点的流水线:

text
[原材料] → [切割] → [上色] → [组装] → [成品]

OpenGL的流水线与其很是相似:

text
顶点数据          顶点着色器              片段着色器
[三角形顶点]  →  [把顶点放到屏幕上]  →  [给每个像素上色]  →  屏幕
   ↑                  ↑                      ↑
(CPU传入)          shader.vert           shader.frag
阶段 做什么 执行次数
顶点着色器 计算每个顶点的屏幕位置 每个顶点执行 1 次
片段着色器 计算每个像素的颜色 每个像素执行 1 次

由此可见,顶点着色器片段着色器像是工厂流水线里的机械臂一样去运作。

对于视频渲染,我们只需要画一个矩形(4 个顶点),然后把视频画面当"贴纸"贴在上面。这就是shader.vertshader.frag的全部工作。

什么是着色器?

着色器(Shader)更确切的说是跑在 GPU 上的小程序,用 GLSL(OpenGL Shading Language)语言编写。语法和 C 语言相似。

在Qt项目里,我们可以把它打包到 resources.qrc 里,在程序启动时加载编译:

cpp
// 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 执行时会自动串联它们。


把形状搭起来吧

glsl
// 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 端代码

cpp
glVertexAttribPointer(0, 2, GL_FLOAT, ...);  // location=0 → aPos
  • layout(location = 1) in vec2 aTexCoord; 同理,输入纹理坐标。纹理坐标的范围是 (0,0) 到 (1,1),(0,0) 是左下角。

CPU 端对应

cpp
glVertexAttribPointer(1, 2, GL_FLOAT, ...);  // location=1 → aTexCoord
  • uniform mat4 transform; uniform 变量是从 CPU 传进来的参数,所有顶点共用同一个值。这里是 4×4 变换矩阵,用来实现旋转、缩放、平移。

CPU 端设置这个值

cpp
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 图形学需要齐次坐标,这里不展开)
    • 乘以变换矩阵,得到最终屏幕位置

顶点数据

cpp
// 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)。

示意图:

code
  (-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) 的常见映射为:
    u=xndc+12,v=yndc+12u = \frac{x_{\text{ndc}} + 1}{2}, \quad v = \frac{y_{\text{ndc}} + 1}{2}
    或者反过来是
    xndc=2u1,yndc=2v1x_{\text{ndc}} = 2u - 1, \quad y_{\text{ndc}} = 2v - 1
    这个映射是线性拉伸:纹理的 (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 计算公式是:

(u,v)=α(u0,v0)+β(u1,v1)+γ(u2,v2),α+β+γ=1(u, v) = \alpha (u_0, v_0) + \beta (u_1, v_1) + \gamma (u_2, v_2), \quad \alpha+\beta+\gamma=1

由于 α,β,γ 在 [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)中,一个顶点的坐标为

(xe,ye,ze)(x_e, y_e, z_e)

沿着视线方向(摄像机看向 −Z 轴)的深度通常定义为:

视线方向是沿着 −Z 轴(即摄像机看向 Z 负方向)。

d=zed = -z_e

z_e 为负时 d 为正。线性深度 d 在摄像机坐标系中随位置线性变化。

对于三角形三个顶点,深度分别为:

d1,d2,d3d_1, d_2, d_3

位置分别为:

P1,P2,P3\mathbf{P}_1, \mathbf{P}_2, \mathbf{P}_3

三角形内任意一点 \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₃,颜色、纹理坐标等也类似。

λ1+λ2+λ3=1P=λ1P1+λ2P2+λ3P3\lambda_1 + \lambda_2 + \lambda_3 = 1 \mathbf{P} = \lambda_1 \mathbf{P}_1 + \lambda_2 \mathbf{P}_2 + \lambda_3 \mathbf{P}_3

因为深度是位置的线性函数(d = -z_e),所以插值后的深度为:

d=λ1d1+λ2d2+λ3d3d = \lambda_1 d_1 + \lambda_2 d_2 + \lambda_3 d_3

这就是在摄像机坐标系中线性插值深度的数学表达式。

同一个三角形内部,不同像素(片元)计算得到的插值深度通常都不一样。

摘自稀土掘金:光栅化阶段 与 片元着色器[^ 1]

image.png

image.png

image.png

输入:顶点 输出:三角形网格 类型:管线固定部分,由管线自动完成

三角形设置 :我们从几何阶段得到了顶点,现在需要得到三角形对像素的覆盖情况,就是三角形的边界的表示形式,即把顶点连接成三角形网格,这个过程计算过程就是三角形设置。

三角形遍历

image.png 输入:三角形网格 输出:覆盖三角形网格的像素位置 类型:管线固定部分,由管线自动完成

根据输入的单个三角形网格的三个顶点,进行插值计算得到覆盖三角形网格的像素位置,对应的像素就生成一个片元。

深度(Depth)本质上是一个距离值,表示片元与摄像机之间的距离(通常经过透视投影和视口变换后归一化到 [0, 1] 范围)。

image.png

输入:片元(像素)信息、顶点数据、法线等 输出:经过修改的片元(像素)信息

根据输入计算片元颜色信息,可实现纹理贴图、法线贴图等等;当前没有影响像素颜色值,仅仅只是计算。

片元操作

片元操作是片段着色器之后帧缓冲写入之前的一系列固定功能处理阶段。

片元是经过光栅化插值、尚未通过可见性测试的“像素候选者”,它携带位置、深度、颜色等插值信息,经过片段着色器和一系列片元操作后,成功者成为屏幕上真正显示的像素。

image.png

经过计算的片元并未直接显示,因为可能存在下列情况

image.png

被物体遮挡

image.png

被半透明的物体遮挡

image.png

所以我们需要进行模版缓冲、深度缓冲、颜色混合之后才能得到最终结果,显示在帧缓存中。

光栅化总结

image.png

GPU管线渲染流程总结

image.png image.png

回归文章

进行简单的一个小总结,有其他细的一点就没准确拎出来讲。

层面 为什么用 [0,1] 正方形?
数学 归一化,让纹理分辨率无关;便于插值保持边界。
硬件 采样器直接乘以尺寸即可得到纹素坐标,高效。
API 设计 所有主流图形 API 强制要求,统一标准。
复用性 同一个模型可换不同分辨率的纹理,UV 不用改。
平铺逻辑 小数部分自然表示周期,加 1 就是重复一次。
教学/可视化 直观,角点对应,避免负数,容易理解。

片段着色器

glsl
// 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 区分:

glsl
// 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 是两种描述颜色的"语言",着色器里的转换函数就是翻译:

glsl
// 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 字段判断用哪个:

cpp
// 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整理

text
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的处理

cpp
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 号快递柜"。

渲染前再逐一激活每个单元并绑定纹理:

cpp
// 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);      // 空的就解绑,防止误采样
        }
    }
	....
}

纹理的创建

cpp
// videorenderer.cpp 第466-481行
void VideoRenderer::initTex(GLuint &tex, const QSize &size,
                            const std::array<unsigned int, 3> &para,
                            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 矩阵,一次性传给着色器:

cpp
// 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 表示。

首先明确变换顺序(代码从上到下执行,每个变换左乘当前矩阵,因此最后的变换最先作用于顶点):

  1. 原始比例 scaleX0, scaleY0(最后一行,最先应用)
  2. 旋转 m_angle(绕 Z 轴)
  3. 缩放+镜像 m_scaleX, m_scaleY
  4. 画面比例修正 scaleX / scaleX0, scaleY / scaleY0
  5. 平移 (m_tx, m_ty)(第一行,最后应用)

对应的顶点变换为:

p=TSratioSmirrorRSorigp\mathbf{p}' = \mathbf{T} \cdot \mathbf{S}_{\text{ratio}} \cdot \mathbf{S}_{\text{mirror}} \cdot \mathbf{R} \cdot \mathbf{S}_{\text{orig}} \cdot \mathbf{p}

各变换的 4×4 矩阵

设原始点齐次坐标 p**=(x,y,0,1)⊤。

① 原始比例

Sorig=(sx00000sy00000100001),sx0=scaleX0,  sy0=scaleY0\mathbf{S}_{\text{orig}} = \begin{pmatrix} s_{x0} & 0 & 0 & 0 \\ 0 & s_{y0} & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \quad s_{x0} = \text{scaleX0},\; s_{y0} = \text{scaleY0}

② 旋转 (绕 Z 轴)

R=(cosθsinθ00sinθcosθ0000100001),θ=mangle\mathbf{R} = \begin{pmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \quad \theta = m_{\text{angle}}

③ 缩放 + 镜像

Smirror=(sx0000sy0000100001),sx=mscaleX,  sy=mscaleY\mathbf{S}_{\text{mirror}} = \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \quad s_x = m_{\text{scaleX}},\; s_y = m_{\text{scaleY}}

④ 画面比例修正

Sratio=(rx0000ry0000100001),rx=scaleXscaleX0,  ry=scaleYscaleY0\mathbf{S}_{\text{ratio}} = \begin{pmatrix} r_x & 0 & 0 & 0 \\ 0 & r_y & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \quad r_x = \frac{\text{scaleX}}{\text{scaleX0}},\; r_y = \frac{\text{scaleY}}{\text{scaleY0}}

⑤ 平移

T=(100tx010ty00100001),tx=mtx,  ty=mty\mathbf{T} = \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \quad t_x = m_{\text{tx}},\; t_y = m_{\text{ty}}

最终矩阵为:

M=TSratioSmirrorRSorig\mathbf{M} = \mathbf{T} \cdot \mathbf{S}_{\text{ratio}} \cdot \mathbf{S}_{\text{mirror}} \cdot \mathbf{R} \cdot \mathbf{S}_{\text{orig}}

展开(从左往右)

M=(100mtx010mty00100001)(rx0000ry0000100001)(sx0000sy0000100001)(cosθsinθ00sinθcosθ0000100001)(sx00000sy00000100001)\mathbf{M} = \begin{pmatrix} 1 & 0 & 0 & m_{tx} \\ 0 & 1 & 0 & m_{ty} \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} r_x & 0 & 0 & 0 \\ 0 & r_y & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} \cos\theta & -\sin\theta & 0 & 0 \\ \sin\theta & \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} s_{x0} & 0 & 0 & 0 \\ 0 & s_{y0} & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

简化后的结果

前三部分(比例+镜像+旋转+原始比例)可先合并为:

Stotal=(rxsxsx0cosθrxsxsy0sinθ00rysysx0sinθrysysy0cosθ0000100001)\mathbf{S}_{\text{total}} = \begin{pmatrix} r_x s_x s_{x0} \cos\theta & -r_x s_x s_{y0} \sin\theta & 0 & 0 \\ r_y s_y s_{x0} \sin\theta & r_y s_y s_{y0} \cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

最后左乘平移矩阵:

M=(rxsxsx0cosθrxsxsy0sinθ0mtxrysysx0sinθrysysy0cosθ0mty00100001)\mathbf{M} = \begin{pmatrix} r_x s_x s_{x0} \cos\theta & -r_x s_x s_{y0} \sin\theta & 0 & m_{tx} \\ r_y s_y s_{x0} \sin\theta & r_y s_y s_{y0} \cos\theta & 0 & m_{ty} \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

这就是最终作用于顶点 (x,y,0,1)⊤(x,y,0,1)⊤ 的变换矩阵。

整体流程

text
 ┌─────────────────────────────────────────────────────────┐
 │ 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 打包进可执行文件
  1. CPU 管粗活:解码、创建纹理、设置参数
  2. GPU 管细活:逐像素的颜色计算(200万个像素同时算)
  3. 着色器是翻译官:把 YUV"翻译"成 RGB,把 BT.601 色彩空间"翻译"成屏幕能显示的 sRGB
  4. 纹理单元是快递柜:yTex→0号柜,uTex→1号柜,片段着色器按柜号取数据

[^ 1]: GPU渲染管线流程GPU渲染管线 几何阶段 光栅化阶段 顶点着色器 片元着色器 模型变换 视图变换 曲面细分 剪裁 三角 - 掘金

© 泠時月 2026,采用 CC BY 4.0 许可,转载保留署名。

留言 · 0 段对话

扫码分享

二维码