Android 实战问题反推 OpenGL 概念(上)

0 阅读15分钟

从 Android 开发者的视角,串起 OpenGL ES、坐标系、Shader、纹理、EGL、FBO 与离屏渲染。

还记得第一次搞OpenGL的时候是2019年,那个时候有个需求就是要在相机上加特效,并且录制成一个MP4,当时也是各种开源框架代码抄,虽然过程中也学习了很多,但是一直并没有写一些能够去反哺的知识,所以最近花时间写了一些文章,这篇是开篇,后面还有2篇。

目前现有的很多文章都会从数学或完整图形管线讲起,但 Android 开发者真正容易卡住的,往往是另一组问题:GL线程是什么,SurfaceTexture 为什么对应 OES 纹理?顶点坐标、纹理坐标和 glViewport 到底各管什么?FBO 是什么?

这篇文章不追求一次讲完所有图形学知识,而是围绕四个能直接落到相机预览、贴纸、水印和录制场景的问题展开。

目录


1. OpenGL、Skia 与 GPU 是什么关系

先给出一个不那么容易误解的结论:OpenGL ES 是跨平台图形 API,通过API直接调用GPU驱动

Skia

Skia 是 Android 图形栈中的重要 2D 图形库,也是一个跨平台项目。它提供 Canvas、Path、Paint 等更贴近 2D 绘制语义的 API,让业务代码关注“画什么”,而不是直接管理 Shader、纹理和渲染状态。

Skia 同时拥有 CPU 光栅化和 GPU 加速后端:简单地说,它既可以在 CPU 上计算像素,也可以通过 OpenGL ES、Vulkan 等底层图形 API 把工作交给 GPU。因此,把 Skia 理解成“只做软件渲染”或“只做 GPU 渲染”都不准确,实际路径取决于 Android 版本、硬件能力和渲染配置。

  • Skia GL 模式:HWUI > Skia > OpenGL ES > GPU
  • Skia VK 模式(现代主力):HWUI > Skia > Vulkan > GPU

GPU 渲染和 CPU 渲染

GPU 的优势是用大量并行执行单元处理结构相似的任务,例如顶点变换、纹理采样和逐片元计算;CPU 则更擅长复杂分支、串行控制和通用逻辑。两者没有绝对的“谁更快”,关键要看任务类型和数据搬运成本。

使用 GPU 渲染通常要经历:创建图形上下文 → 准备或上传数据 → GPU 执行渲染 → 将结果显示、编码,或在必要时读回 CPU。 如果最终还要频繁调用 glReadPixels 把结果读回 CPU,数据同步和传输成本可能抵消 GPU 的优势。

GPU 很擅长画三角形、贴图和矩阵变换,但路径细分、复杂抗锯齿等 2D 操作也会带来额外成本。Android 的 GPU 型号和驱动实现又十分多样,因此实际性能不能只看理论算力,还要在目标设备上测量。

GPU 驱动到底在哪里

应用调用 OpenGL ES API 后,请求会经过系统图形库和厂商用户态驱动,最终由内核态驱动与 GPU 硬件交互。不同 Android 版本和厂商的加载机制并不完全一样,但厂商图形库通常可以在 /vendor/lib/egl/、/vendor/lib64/egl/ 或相关 vendor 分区路径中找到,例如 Adreno 或 Mali 对应的 .so 文件。

芯片平台和 GPU 品牌不能完全画等号,不过常见组合包括高通平台搭配 Adreno、许多联发科或 Rockchip 平台搭配 Mali。以一台 RK3568 开发板为例,vendor 中可以看到如下文件:

vendor/rockchip/common/gpu/MaliG52$ tree
.
└── lib
    ├── arm
    │   ├── libGLES_mali_dr2.so
    │   ├── libGLES_mali_r0.so
    │   ├── libGLES_mali_r1.so
    │   ├── libGLES_mali.so
    │   ├── libgpudataproducer.so
    │   └── vulkan.mali.so
    ├── arm64
    │   ├── libGLES_mali_dr2.so
    │   ├── libGLES_mali_r0.so
    │   ├── libGLES_mali_r1.so
    │   ├── libGLES_mali.so
    │   ├── libgpudataproducer.so
    │   └── vulkan.mali.so
    ├── modules
    │   └── mali_kbase.ko
    └── vendor
        ├── lib
        │   └── egl
        │       └── libGLES_mali.so
        └── lib64
            └── egl
                └── libGLES_mali.so

这里的 .so 是用户态图形库,.ko 则是可加载的内核模块。两者都属于完整 GPU 驱动栈的一部分,但职责不同。

其次就是内核的GPU,用户态驱动把绘图指令翻译好之后,需要通过系统调用(ioctl)交给内核态驱动。内核态驱动(如 msm/adreno 或 panfrost)负责真正地管理 GPU 显存分配、电源管理、以及把指令推送到 GPU 硬件的寄存器中。

Snipaste_2026-10-12_15-50-41.png

CUDA、DirectX 与 Metal

这几个名字经常一起出现,但定位不同:OpenGL / OpenGL ES 和 Direct3D 主要提供图形渲染能力;Metal 同时覆盖图形与通用计算;CUDA 则是面向 NVIDIA GPU 的并行计算平台,不要求开发者把计算任务伪装成传统渲染流程。

早期 GPGPU(通用计算)的痛苦时代:强行套用 OpenGL,在 CUDA 出现之前,计算机科学家们发现 GPU 算浮点数比 CPU 快太多了,想用它来做科学计算、密码破解。但是当时的 GPU 只有 OpenGL/DirectX 这种图形 API。

  • 做法: 他们必须把科学计算的数据伪装成一张图片(Texture),把计算公式写成像素着色器(Fragment Shader),然后让 OpenGL 画一个铺满屏幕的三角形。GPU 以为自己在渲染画面,其实是在算矩阵乘法。最后把渲染结果(Framebuffer)读出来转换成数据。

  • 缺点: 极度反人类。你需要懂计算机图形学才能做科学计算,而且大部分 GPU 硬件资源(如光栅化器、纹理采样器)被白白浪费了。

后来 CUDA 提供了更直接的并行计算模型,开发者不再需要把所有通用计算都包装成“绘制一张纹理”,对图形渲染知识的依赖也显著降低。

2. 坐标系与着色器如何配合

先分清三套坐标

  • 裁剪空间与 NDC:顶点着色器输出的是裁剪空间坐标 gl_Position;完成透视除法后才得到 NDC。NDC 的 X、Y 范围通常是 [-1, 1],原点在视口中心,Y 轴向上。

  • OpenGL 纹理坐标系(ST / UV):原点 (0,0) 在左下角,范围是 [0, 1],Y 轴向上。

  • Android 图像与相机流:Bitmap 的行序、资源上传方式和流生产者可能带来方向差异;SurfaceTexture 还会提供专用变换矩阵。不要只靠“Android 原点一定在左上角”推导结果,应以实际数据路径和变换矩阵为准。

通常情况下来说他们是1-1对应的,比如顶点坐标系的-1,-1,应该就是对应纹理坐标系的0,0。之所以建议进行1-1对应,是因为如果位置不对,我们应该是引入矩阵来进行处理。

// 铺满全屏的顶点坐标  
private val vertexData = floatArrayOf(  
    -1.0f, -1.0f,  
    1.0f, -1.0f,  
    -1.0f,  1.0f,  
    1.0f,  1.0f  
)  
  
// 对应的纹理坐标  
private val textureData = floatArrayOf(  
    0.0f, 0.0f,  
    1.0f, 0.0f,  
    0.0f, 1.0f,  
    1.0f, 1.0f  
)

private val vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(vertexData.size * 4)  
    .order(ByteOrder.nativeOrder()).asFloatBuffer().put(vertexData).apply { position(0) }  
private val textureBuffer: FloatBuffer = ByteBuffer.allocateDirect(textureData.size * 4)  
    .order(ByteOrder.nativeOrder()).asFloatBuffer().put(textureData).apply { position(0) }

这里有一个常见疑问:顶点数组和纹理坐标为什么要放进 Direct ByteBuffer?核心原因不是“防止 GC 一移动对象就让驱动崩溃”——JNI 会负责必要的固定或拷贝——而是 OpenGL ES 的 Java 绑定需要一段可被 Native 代码直接访问、布局明确的连续内存。Direct Buffer 可以减少额外转换,也更适合传给底层 API。

不过,Direct Buffer 仍位于 CPU 可访问内存。若每帧都从客户端内存提交大量顶点数据,仍可能产生传输成本;更稳定的静态数据通常可以进一步放入 VBO,后文再展开。

着色器到底是什么

我们平时写的主程序代码,大部分都是在CPU上运行的,而着色器代码和主程序之间的关系就在于着色器代码是在GPU上执行的,主程序是在CPU上执行的。 主程序会把数据传给着色器,例如顶点数据,着色器代码就能够处理这些数据,并将结果返回给主程序。

  1. 在 Vertex shader 中 main 函数中最终得到的结果是顶点坐标 gl_Position (顶点位置,类似于图钉)
  2. 在 Fragment shader 中 main 函数中计算得到的最终结果是像素点的颜色值 gl_FragColor (每个像素的颜色)

着色器虽然里面有很多渲染管线的知识,但是我们一般来说只需要懂得什么是顶点和片元着色器,我们把着色器理解为一个画笔,对,就是Android中Canvas的Paint,那么坐标系就是决定了首先我们画框(顶点坐标)有多大,一般都是全屏,而画布(纹理坐标)决定了我们画布和画框之间到底要不要完全一致,一般来说都是一样的。具体的流程可以想象一下,当我们调用OpenGL去画一张1920x1080大小的红色矩形,他的一个的完整流水线:

  1. 顶点着色器 (Vertex Shader) —— 确定画框的四个角

    • 对象是“顶点”,不是像素: 在当前四顶点矩形的示例中,顶点着色器执行 4 次。

    • 行为: 它接收这 4 个角的坐标,输出 4 个最终的位置 gl_Position。同时,它把这 4 个角对应的纹理坐标 vTexCoord 记录下来,准备传给下一步。此时,屏幕上什么都没有,只有虚空中的四个点。

  2. 图元装配与光栅化 (Rasterization) —— 连线并砸成像素(关键转折点)

    • 连线: GPU 拿到这 4 个点,把它们连成两个三角形(因为 GPU 只会画三角形)。

    • 光栅化(填格子): GPU 计算出这两个三角形在 1920x1080 的屏幕上,到底覆盖了哪 2,073,600 个像素(1920 * 1080)。

    • 自动插值: 它知道左上角 vTexCoord 是 (0,1),右上角是 (1,1),因此能推算三角形内部各片元对应的纹理坐标。顶点着色器输出的 varying 正是在这一阶段被自动插值的。

  3. 片段/片元着色器 (Fragment Shader) —— 疯狂上色

    • 对象是“片元”: 对于覆盖全屏的图元,片元着色器通常会产生与像素规模相当的大量调用;多重采样、裁剪、深度测试等因素会让实际次数与物理像素数不完全相等。

    • 行为: 每个像素在执行时,都会收到光栅化阶段发给它的专属、插值后的 vTexCoord。

    • 结果:

      • 如果你只是想画一个纯红色的矩形,这里直接写死输出 gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 即可,不需要纹理。

      • 如果你想贴一张图,片段着色器就会拿着这个专属的 vTexCoord,调用 texture2D 去图片对应的位置取颜色,然后赋值给 gl_FragColor。

  4. 测试与混合 (Testing & Blending) —— 最终输出

    • GPU 检查这些红色的像素有没有被其他东西挡住(深度测试),或者需不需要半透明叠加(混合),最后写入帧缓冲区(FrameBuffer),显示在你的屏幕上。

顶点着色器

val vertexShaderCode = """
    // 1. attribute:只能用在顶点着色器。代表每个顶点专属的数据
    attribute vec4 a_Position; // 接收顶点坐标 (比如 -1 到 1 的 NDC 坐标)
    attribute vec2 a_TexCoord; // 接收纹理坐标 (比如 0 到 1 的画布坐标)
    
    // 2. uniform:全局变量。一次绘制过程中,所有顶点和像素都共享这个值
    uniform mat4 u_Matrix;     // 接收一个 4x4 的变换矩阵(用来旋转、缩放或修正上下颠倒)
    
    // 3. varying:桥梁变量。用于把顶点着色器的数据,透传给片元着色器
    varying vec2 v_TexCoord;   // 准备传给下一个阶段的纹理坐标
    
    void main() {
        // gl_Position 是 OpenGL 内置的终极输出变量,代表顶点在屏幕上的最终位置
        gl_Position = u_Matrix * a_Position; 
        
        // 把传进来的纹理坐标原封不动地交给 v_TexCoord
        v_TexCoord = a_TexCoord; 
    }
""".trimIndent()

片元着色器

val fragmentShaderCode = """
    // 1. 声明浮点数的计算精度(中等精度即可)
    precision mediump float; 
    
    // 2. varying:必须和顶点着色器里的名字、类型一模一样!
    // 这里的 v_TexCoord 已经被光栅化阶段“自动插值”过了,代表当前这个像素的具体纹理位置
    varying vec2 v_TexCoord; 
    
    // 3. uniform sampler2D:专门用来接收纹理(图片)数据的特殊类型
    uniform sampler2D u_Texture; 
    
    void main() {
        // gl_FragColor 是 OpenGL 内置的终极输出变量,代表当前像素的最终颜色
        // texture2D(图片, 坐标) 是内置函数,代表用画笔去画布的指定位置“取色”
        gl_FragColor = texture2D(u_Texture, v_TexCoord);
    }
""".trimIndent()

一次绘制的完整链路

  • 确定画框:传入顶点坐标 aPosition,乘以位置矩阵(如需缩放)赋给 gl_Position,圈定屏幕上的绘制区域。

  • 确定相片采样区域:传入纹理坐标 aTexCoord,乘以校正矩阵 uSTMatrix 并取 .xy,赋给 varying 变量 vTexCoord。

  • 管线插值:光栅化硬件在画框内部自动将这几个角的 vTexCoord 线性插值成屏幕上成千上万个像素的坐标。

  • 采样上色:每个片元用自己的 vTexCoord 去 sTexture 监听的纹理单元里取色(texture2D),将结果输出给 gl_FragColor,完成画面渲染。

接下来回答一个很容易混淆的问题:顶点坐标和纹理坐标范围不一致时会怎样?如果画框仍覆盖全屏,而纹理坐标只覆盖原图的一部分,那么这一小块采样区域会被拉伸到整个画框中,看起来就像“局部放大”,而不是画面只占半屏。

  • aPosition(画框):决定了屏幕上哪块区域会被画上东西。

    • 如果 aPosition 是全屏 [−1,1][-1, 1],那么整个屏幕的每个像素都会被 GPU 着色。
  • aTexCoord(放大镜 / 采样范围):决定了从原图中截取哪一部分塞进画框。

    • UV 是 [0.0,1.0][0.0, 1.0]:截取整张图片,塞进画框。

    • 只有 U 使用 [0.0, 0.5]:截取原图横向一半;U、V 都使用 [0.0, 0.5]:截取原图一个象限。两种情况都会把选中的区域拉伸到整个画框。

  • 如果你想让画面在屏幕上只占一半(留出黑边),你应该缩小 aPosition(画框)。

  • 如果你想做数码变焦(局部放大),你才去缩小 aTexCoord(采样范围)。

glViewport 又负责什么

glViewport(x, y, width, height) 的本质是建立“虚拟世界 NDC”与“物理屏幕像素”之间的映射桥梁。

顶点着色器输出的 gl_Position 位于裁剪空间;经过透视除法得到 NDC 后,glViewport 再把 NDC 映射到窗口像素区域。Shader 不需要知道手机是 1080 × 1920 还是 1080 × 2400,但视口必须与当前输出目标匹配。

当纹理比例与屏幕比例不一致时,以谁为准

这是相机/视频开发中最经典的场景:相机输出是 16:916:9,但手机屏幕是 20:920:9。

如果什么都不做,直接把相机贴到全屏画框上,画面就会被上下拉长变形(人脸变长)。此时有三种行业标准的对齐方案:

方案 A:等比例居中,留黑边(FitCenter / Letterbox)

  • 做法:glViewport 依然设为全屏,但通过矩阵把 aPosition(画框)在高度上压窄一点。

  • 效果:画面比例完美保真,屏幕上下留黑边。

方案 B:等比例铺满,裁剪溢出边缘(CenterCrop)

  • 做法:aPosition 依然铺满全屏,但通过纹理矩阵对 aTexCoord 进行缩放,只截取中间 20:920:9 的部分画面。

  • 效果:全屏无黑边,但相机的左右或上下两边会被切掉一小块。

方案 C:直接改动 Viewport 窗口

  • 做法:计算居中的视口区域,只把视口开在屏幕中间,例如 glViewport(0, 240, 1080, 1920)。

  • 效果:OpenGL 只向中间区域绘制,视口外面的区域自然保留清屏背景色(黑色)。

本节小结

  • 顶点坐标决定“画到哪里”,纹理坐标决定“从哪里取样”,glViewport 决定 NDC 如何映射到当前输出目标的像素区域。
  • 顶点着色器先处理少量顶点,光栅化阶段生成片元并插值纹理坐标,片元着色器再完成采样与着色。
  • 缩小顶点范围会缩小画框;缩小纹理坐标范围会裁取局部并放大到画框。
  • 比例不一致时,应明确选择拉伸、FitCenter 或 CenterCrop,不能期待 OpenGL 自动判断业务意图。

一句话记忆:顶点坐标管“画框”,纹理坐标管“取哪块图”,Viewport 管“这幅画最终落在哪些像素上”。

如果想继续补渲染管线的直觉,可以再找可视化资料辅助理解;关于渲染管线的知识,我推荐大家去看一个B站视频,叫做上帝视角看GPU。我这里就简单做个扫盲就行了。


3. 状态机、纹理与采样器是什么关系

状态机模式到底是什么

很多文章把状态机解释成“上一步会影响下一步”,但这还不够准确。更实用的理解是:一个 OpenGL Context 保存着一组当前状态,后续绘制命令会读取这些状态。 它不是整个应用只有一个全局对象,而是每个 Context 都有自己的状态集合。

先看我们更熟悉的面向对象写法:

// 面向对象的方式:状态归属于具体的 View 或 Object
View boxA = new View();
boxA.setBackgroundColor(Color.RED); 
boxA.draw(); // 画出来是红色

View boxB = new View();
// boxB 没有设置颜色
boxB.draw(); // 画出来是默认颜色(比如透明或白色),绝对不会是红色!

在这里,boxA 的状态和 boxB 是完全隔离的。

OpenGL 当然也有纹理、Buffer、Program 等对象,但 API 的调用方式不是 texture.draw(),而是先把对象绑定到 Context 的某个状态槽位,再发出绘制命令。例如:

// 当前激活的纹理单元是 GL_TEXTURE0
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureA)
drawBoxA() // 采样 textureA

// 如果忘记绑定 textureB,后续绘制仍会沿用 textureA
drawBoxB() // 仍然采样 textureA

GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureB)
drawBoxB() // 现在才会采样 textureB

所以,更准确的说法是:你修改了当前 Context 的状态,后续命令会一直沿用,直到它再次被显式修改。

如果你做过 Android 自定义 View 开发,可以把 OpenGL 状态机类比成:在一次绘制过程中反复复用同一个 Paint。

// 假设整个系统只能用这一个 paint
Paint globalPaint = new Paint(); 

// 绘制过程
globalPaint.setColor(Color.RED);  // 改变状态
canvas.drawRect(rectA, globalPaint); // 画动作:红色矩形

// 如果你忘了改颜色,直接画下一个
canvas.drawCircle(circleB, globalPaint); // 画动作:红色圆形(状态被继承了)

// 必须显式修改状态
globalPaint.setColor(Color.BLUE); // 改变状态
canvas.drawLine(lineC, globalPaint); // 画动作:蓝色线条

各种 glEnable、glBindTexture、glUseProgram 都是在修改当前 Context 的状态,而 glDrawArrays / glDrawElements 会使用发出命令那一刻的状态。这个类比不覆盖全部细节,但足以解释为什么状态泄漏会导致“上一段代码没解绑,下一段画错了”的问题。

渲染管线流程

下面用一个 OesDrawer 把前面的概念串起来。代码不复杂,重点是观察 Shader、纹理和 Context 状态如何在一次 Draw Call 中汇合。

class OesDrawer {

    // 顶点着色器:处理位置和矩阵变换
    private val vertexShaderCode = """
        attribute vec4 aPosition;
        attribute vec4 aTexCoord;
        uniform mat4 uSTMatrix;
        varying vec2 vTexCoord;
        void main() {
            gl_Position = aPosition;
            vTexCoord = (uSTMatrix * aTexCoord).xy;
        }
    """.trimIndent()

    // 片段着色器:采样 OES 纹理并输出颜色 (核心宏定义不可少)
    private val fragmentShaderCode = """
        #extension GL_OES_EGL_image_external : require
        precision mediump float;
        varying vec2 vTexCoord;
        uniform samplerExternalOES sTexture;
        void main() {
            gl_FragColor = texture2D(sTexture, vTexCoord);
        }
    """.trimIndent()

    private var program = 0
    private var aPositionHandle = 0
    private var aTexCoordHandle = 0
    private var uSTMatrixHandle = 0
    private var textureSamplerHandle = 0

    // 铺满全屏的顶点坐标
    private val vertexData = floatArrayOf(
        -1.0f, -1.0f,
        1.0f, -1.0f,
        -1.0f,  1.0f,
        1.0f,  1.0f
    )

    // 对应的纹理坐标
    private val textureData = floatArrayOf(
        0.0f, 0.0f,
        1.0f, 0.0f,
        0.0f, 1.0f,
        1.0f, 1.0f
    )

    private val vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(vertexData.size * 4)
        .order(ByteOrder.nativeOrder()).asFloatBuffer().put(vertexData).apply { position(0) }
    private val textureBuffer: FloatBuffer = ByteBuffer.allocateDirect(textureData.size * 4)
        .order(ByteOrder.nativeOrder()).asFloatBuffer().put(textureData).apply { position(0) }

    fun init() {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)

        try {
            program = GLES20.glCreateProgram()
            
            GLES20.glAttachShader(program, vertexShader)
            GLES20.glAttachShader(program, fragmentShader)
            GLES20.glLinkProgram(program)
            val linkStatus = IntArray(1)
            GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
            if (linkStatus[0] != GLES20.GL_TRUE) {
                val infoLog = GLES20.glGetProgramInfoLog(program)
                GLES20.glDeleteProgram(program)
                program = 0
                error("Program 链接失败:$infoLog")
            }

            aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
            aTexCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
            uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
            textureSamplerHandle = GLES20.glGetUniformLocation(program, "sTexture")
           
        } catch (error: Exception) {
            release()
            throw error
        } finally {
            GLES20.glDeleteShader(vertexShader)
            GLES20.glDeleteShader(fragmentShader)
        }
    }

    fun draw(textureId: Int, transformMatrix: FloatArray) {

        GLES20.glUseProgram(program)

        // 激活 OES 纹理
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
        GLES20.glUniform1i(textureSamplerHandle, 0)

        // 传入顶点数据
        GLES20.glEnableVertexAttribArray(aPositionHandle)
        GLES20.glVertexAttribPointer(aPositionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)

        // 传入纹理坐标数据
        GLES20.glEnableVertexAttribArray(aTexCoordHandle)
        GLES20.glVertexAttribPointer(aTexCoordHandle, 2, GLES20.GL_FLOAT, false, 0, textureBuffer)

        // 传入 SurfaceTexture 的纹理坐标变换矩阵。
        GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, transformMatrix, 0)

        // 绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        GLES20.glDisableVertexAttribArray(aPositionHandle)
        GLES20.glDisableVertexAttribArray(aTexCoordHandle)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
        GLES20.glUseProgram(0)

    }

    /** 必须在创建此程序的 GL 上下文仍有效时调用。 */
    fun release() {
        if (program != 0) {
            GLES20.glDeleteProgram(program)
            program = 0
        }
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        val shader = GLES20.glCreateShader(type)
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)
        val compileStatus = IntArray(1)
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
        if (compileStatus[0] != GLES20.GL_TRUE) {
            val infoLog = GLES20.glGetShaderInfoLog(shader)
            GLES20.glDeleteShader(shader)
            error("OES 着色器编译失败,type=$type:$infoLog")
        }
        return shader
    }
}

从 init() 开始看:创建、编译并链接 Shader 后,通过 glGetAttribLocation 和 glGetUniformLocation 查询变量在当前 Program 中的位置。它们返回的是 Program 内的位置值,不是在创建变量,也不是通用对象句柄;切换 Program 后不能继续沿用旧位置。

aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
aTexCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
textureSamplerHandle = GLES20.glGetUniformLocation(program, "sTexture")

接下来进入 draw()。在读代码之前,先区分纹理 ID、纹理目标、纹理单元和采样器:

核心概念现实比喻核心职能输入 / 输出对应核心代码
纹理 ID画纸的编号OpenGL 分配的纹理对象名称;不是显存物理地址标识纹理对象glGenTextures
纹理目标画纸类型决定绑定与采样规则,如 2D / External OES约束纹理用途glBindTexture
纹理单元可切换的卡槽保存当前绑定到各纹理目标的对象,供 Shader 选择组织采样输入glActiveTexture
采样器 Uniform指向卡槽的编号告诉 Shader 从哪个纹理单元采样选择输入glUniform1i

纹理 ID 和纹理目标

glGenTextures 生成的 ID 本质相同,第一次绑定的目标和后续用法决定了它如何被使用。OpenGL 还有立方体纹理、3D 纹理、纹理数组等类型;本文只讨论 Android 相机链路中最常见的两种:

  1. GL_TEXTURE_EXTERNAL_OES:接收 SurfaceTexture 对应的外部图像流。
  2. GL_TEXTURE_2D:普通二维纹理,可上传 Bitmap,也可作为 FBO 的颜色附件。
//OES 纹理创建
fun createOesTexture(): Int {
    val textures = IntArray(1)
    GLES20.glGenTextures(1, textures, 0)
    val oesTextureId = textures[0]
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId)
    
    GLES20.glTexParameteri(
        GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR,
    )
    GLES20.glTexParameteri(
        GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR,
    )
    GLES20.glTexParameteri(
        GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE,
    )
    GLES20.glTexParameteri(
        GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE,
    )
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)

    surfaceTexture = SurfaceTexture(oesTextureId).apply {
        setDefaultBufferSize(PREVIEW_WIDTH, PREVIEW_HEIGHT)
        setOnFrameAvailableListener(this@CameraOesEngine, glHandler)
    }
    cameraInputSurface = Surface(surfaceTexture)
    Log.i(TAG, "OES 输入 Surface 已创建,textureId=$oesTextureId")
    return oesTextureId
}

// 分配一张空的 2D 纹理,可作为 FBO 颜色附件
fun create2DTexture(width: Int, height: Int): Int {
   val textureIds = IntArray(1)
   GLES20.glGenTextures(1, textureIds, 0)
   GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIds[0])

   GLES20.glTexImage2D(  
        GLES20.GL_TEXTURE_2D,  
        0,  
        GLES20.GL_RGBA,  
        width,  
        height,  
        0,  
        GLES20.GL_RGBA,  
        GLES20.GL_UNSIGNED_BYTE,  
        null  
    )

   GLES20.glTexParameteri(
       GLES20.GL_TEXTURE_2D,
       GLES20.GL_TEXTURE_MIN_FILTER,
       GLES20.GL_LINEAR
   )
   GLES20.glTexParameteri(
       GLES20.GL_TEXTURE_2D,
       GLES20.GL_TEXTURE_MAG_FILTER,
       GLES20.GL_LINEAR
   )
   GLES20.glTexParameteri(
       GLES20.GL_TEXTURE_2D,
       GLES20.GL_TEXTURE_WRAP_S,
       GLES20.GL_CLAMP_TO_EDGE
   )
   GLES20.glTexParameteri(
       GLES20.GL_TEXTURE_2D,
       GLES20.GL_TEXTURE_WRAP_T,
       GLES20.GL_CLAMP_TO_EDGE
   )

   GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)

   return textureIds[0]
}

两段代码都先通过 glGenTextures 取得纹理对象名称,再绑定到具体目标并设置采样参数。不同点在于:普通 2D 纹理通常由应用通过 glTexImage2D 分配存储或上传像素;External OES 纹理的图像存储来自外部生产者,因此不能用同样的方式调用 glTexImage2D。

GLES20.glTexImage2D(
    GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, 
    width, height, 0, 
    GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null
);	

摄像头或解码器通常把画面写入 Android 图形缓冲区队列。SurfaceTexture 在 updateTexImage() 时把最新可用图像关联到 External OES 纹理,应用无需先把每帧搬进 Java Bitmap 再上传,因此通常可以避免应用侧的 CPU 像素拷贝。底层是否发生格式转换或额外拷贝取决于设备与驱动实现,不宜把它绝对化为“任何设备都完全零拷贝”。

在 Shader 中使用 samplerExternalOES 采样时,应用拿到的是可直接参与后续计算的颜色值;相机常见的 YUV 存储、色彩转换等细节由图形栈与驱动处理。SurfaceTexture.getTransformMatrix() 返回的矩阵可能随帧变化,应在每次 updateTexImage() 后重新获取并用于采样坐标。

纹理对象的常见准备流程可以概括为三步:

  1. 用 glGenTextures 生成纹理对象名称。
  2. 绑定到纹理目标,并设置过滤、环绕等参数。
  3. 为普通纹理分配或上传图像;External OES 纹理则由外部图像流提供内容。

纹理单元和采样器

// OES
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)  // 激活 0 号纹理单元
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)  
GLES20.glUniform1i(textureSamplerHandle, 0)
....
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)

// 2D
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)   // 激活 0 号纹理单元
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)  // 把纹理绑定到当前单元的 2D 目标
GLES20.glUniform1i(uTextureLocation, 0) // 让 sampler 读取 0 号纹理单元
....
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
  • 切换当前操作的卡槽:glActiveTexture(GL_TEXTURE0) 告诉 OpenGL:“我接下来要对 0 号卡槽 做操作”。
  • 把卡带插入卡槽:glBindTexture(GL_TEXTURE_2D, textureID) 把具体的纹理绑定到当前激活的 0 号卡槽 上。
  • 着色器指定卡槽:通过 glUniform1i(loc, 0) 告诉着色器:“你的 sampler2D 去盯住 0 号卡槽”。
  • 渲染绘制:当片元着色器执行 texture(ourTexture, texCoords) 时,它读取 0 号槽内插着的纹理像素。

OpenGL 具有两个相关的初始状态:

  1. GL_TEXTURE0 是状态机默认激活的单元。
  2. 着色器里的 uniform int 默认值就是 0。

即便如此,工程代码仍建议显式设置 glActiveTexture 和 sampler Uniform,避免逻辑依赖隐含默认值。

0 号之外还有更多纹理单元。当一个 Shader 同时需要底图、LUT、蒙版、噪声图或贴纸时,就会把不同纹理绑定到不同单元,再让各个 sampler 指向对应编号。

  1. 着色器端(OpenGL ES 2.0 Fragment Shader)
precision mediump float;
varying vec2 TexCoord;

uniform sampler2D textureWall;  // 期望监听 0 号纹理单元
uniform sampler2D textureFace;  // 期望监听 1 号纹理单元

void main() {
    gl_FragColor = mix(
        texture2D(textureWall, TexCoord),
        texture2D(textureFace, TexCoord),
        0.2
    );
}
  1. 初始化阶段:配置 Uniform 绑定槽位
GLES20.glUseProgram(shaderProgram)

// 告诉 textureWall 对应 0 号单元,textureFace 对应 1 号单元
// 这个赋值只需在初始化或切换着色器时设置一次,不需要每帧都调
GLES20.glUniform1i(GLES20.glGetUniformLocation(shaderProgram, "textureWall"), 0)
GLES20.glUniform1i(GLES20.glGetUniformLocation(shaderProgram, "textureFace"), 1)
  1. 渲染循环
GLES20.glUseProgram(shaderProgram)

// --- 处理第 0 号单元 ---
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, wallTexID)

// --- 处理第 1 号单元 ---
GLES20.glActiveTexture(GLES20.GL_TEXTURE1)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, faceTexID)

// --- 执行绘制 ---
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

单输入拷贝管线可能只用一个纹理单元,但多纹理在合成、滤镜、蒙版与 LUT 场景中非常常见。 总结下来就是:

+--------------------+        插卡        +----------------------+
| 纹理数据 (textureID) |  ------------->  | 纹理单元 (GL_TEXTURE0) |
+--------------------+   glBindTexture    +----------------------+
                                                     ▲
                                                     | 采样数据
                                                     |
                                          +----------------------+
                                          | sampler2D 变量 (值 = 0)|  (着色器内)
                                          +----------------------+
                                                     ▲
                                                     | 设置监听槽位
                                          glUniform1i(location, 0)

对当前文章的采样场景来说,GL_TEXTURE_2D 和 GL_TEXTURE_EXTERNAL_OES 都可以作为 Shader 的图像输入,但它们是不同的纹理目标,支持的操作和 sampler 类型也不同,不能混用。

到这里,纹理 ID、纹理目标、纹理单元和 Shader 中的 sampler 就串起来了。

回到 draw(),下面几行分别提交顶点属性,并把 SurfaceTexture 的纹理变换矩阵写入 Uniform:

// 传入顶点数据  
GLES20.glEnableVertexAttribArray(aPositionHandle)  
GLES20.glVertexAttribPointer(aPositionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)  
  
// 传入纹理坐标数据  
GLES20.glEnableVertexAttribArray(aTexCoordHandle)  
GLES20.glVertexAttribPointer(aTexCoordHandle, 2, GLES20.GL_FLOAT, false, 0, textureBuffer)  
  
// 传入 SurfaceTexture 的纹理坐标变换矩阵。  
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, transformMatrix, 0)

到这里,完整绘制流程可以整理为:

  1. 定义顶点坐标和纹理坐标;需要旋转、裁剪或翻转时使用矩阵处理。相机流应使用 SurfaceTexture.getTransformMatrix() 返回的 4 × 4 矩阵。
  2. 把顶点数据放入 Direct ByteBuffer,或进一步上传到 VBO。
  3. 编写顶点着色器和片元着色器,声明 attribute、uniform、varying 等输入输出。
  4. 编译 Shader、链接 Program,并检查编译与链接状态。
  5. 创建纹理对象,绑定纹理目标与纹理单元,再让 sampler 指向对应的单元编号。
  6. 启用顶点属性并提交顶点、纹理坐标,设置矩阵等 Uniform。
  7. 调用 glDrawArrays 或 glDrawElements 发出绘制命令。
  8. 按渲染器约定恢复或解绑会影响后续流程的状态。

Shader 与固定功能阶段的衔接可以这样理解:

  • 拉取顶点数据(attribute 接收):GPU 从 ByteBuffer 中按顶点取出顶点坐标和纹理坐标,分别传入顶点着色器的 attribute 变量(如 aPosition、aTexCoord),有几个顶点就执行几次。

  • 计算顶点最终位置(输出 gl_Position):在顶点着色器中,将顶点坐标 aPosition(如有需要则乘上变换矩阵 uMatrix)赋值给内置变量 gl_Position,确定顶点在屏幕上的最终位置。

  • 转交纹理坐标(varying 抛出):因为片元着色器不能直接读取 attribute,顶点着色器需要把 aTexCoord(或乘上纹理矩阵后的结果)赋值给桥梁变量 varying(如 vTexCoord)向外抛出。

  • 图元装配与光栅化插值(GPU 硬件自动接管):GPU 硬件把每 3 个顶点连成三角形,算出三角形内部框住了多少个像素格,并按每个像素离 3 个顶点的距离权重,自动把 vTexCoord 平滑插值成每个像素专属的纹理坐标。

  • 片元着色器启动与纹理采样(texture2D 取色):三角形内围了多少个像素,片元着色器就执行多少次。每次执行时,拿着插值好的 vTexCoord 和绑定的采样器 uTexture,调用 texture2D() 去纹理单元对应的图片位置吸取颜色。

  • 像素颜色计算与输出(输出 gl_FragColor):对采样出来的颜色进行滤镜、调色或透明度计算,将最终算出的 RGBA 颜色赋值给片元着色器内置输出变量 gl_FragColor(ES 3.0 为自定义 out vec4)。

  • 逐片元测试与写入帧缓冲:输出颜色经过已启用的裁剪、深度、模板、混合等阶段后,写入当前绑定 Framebuffer 的附件。只有当前目标是可显示的默认 Framebuffer 并完成交换时,结果才会真正上屏。

本节小结

  • OpenGL 是 Context 级状态机:先设置状态,再发出 Draw Call;状态会持续到下一次修改。
  • 纹理 ID 是对象名称,纹理目标规定用法,纹理单元保存绑定关系,sampler Uniform 选择要读取的单元。
  • GL_TEXTURE_2D 和 GL_TEXTURE_EXTERNAL_OES 不能混用;OES 纹理需要 samplerExternalOES 和对应扩展声明。
  • 一次绘制的核心不是某一行 API,而是 Program、顶点输入、纹理绑定、Uniform 与当前 Framebuffer 在 Draw Call 时同时处于正确状态。

一句话记忆:纹理 ID 是“哪张图”,纹理目标是“它是什么类型”,纹理单元是“图插在哪个槽”,sampler 是“Shader 去读哪个槽”。


4. 离屏渲染、FBO 与 Pbuffer

终于聊到 FBO。很多人第一次接触它,是因为滤镜、贴纸或“二次渲染”,于是容易把 FBO 误解成“做特效才需要的东西”。更准确地说,FBO 是一种把绘制结果写入纹理等附件的离屏目标。

先从一个实际需求开始:在相机画面上叠加静态贴纸和动态时间水印,同时输出到屏幕并录制。这个需求不用 FBO 也能实现,只是随着图层和输出目标增加,重复绘制的成本会越来越明显。

先不用 FBO:直接添加贴图

class BitmapDrawer (var context: Context){

    //创建着色器代码
    private val vertexShaderCode = """
        attribute vec4 aPosition;
        attribute vec2 aTexCoord;
        varying vec2 vTexCoord;
        void main() {
            vTexCoord = aTexCoord;
            gl_Position = aPosition;
        }
    """.trimIndent()

    private val fragmentShaderCode = """
        precision mediump float;
        varying vec2 vTexCoord;
        uniform sampler2D uTexture;
        void main() {
            gl_FragColor = texture2D(uTexture, vTexCoord);
        }
    """.trimIndent()


    private var program: Int = 0
    private var textureId: Int = 0

    private var aPositionLocation: Int = 0
    private var aTexCoordLocation: Int = 0
    private var uTextureLocation: Int = 0

    // 铺满全屏的顶点坐标
    private val vertexData = floatArrayOf(
        -1.0f, -1.0f,
        1.0f, -1.0f,
        -1.0f,  1.0f,
        1.0f,  1.0f
    )

    // 对应的纹理坐标
    private val textureData = floatArrayOf(
        0.0f, 0.0f,
        1.0f, 0.0f,
        0.0f, 1.0f,
        1.0f, 1.0f
    )


    private val vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(vertexData.size * 4)
        .order(ByteOrder.nativeOrder()).asFloatBuffer().put(vertexData).apply { position(0) }
    private val textureBuffer: FloatBuffer = ByteBuffer.allocateDirect(textureData.size * 4)
        .order(ByteOrder.nativeOrder()).asFloatBuffer().put(textureData).apply { position(0) }


    fun init() {


        // 创建顶点着色器
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
        // 创建片元着色器
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)

        try {

            program = GLES20.glCreateProgram()

            GLES20.glAttachShader(program, vertexShader)
            GLES20.glAttachShader(program, fragmentShader)
            // 7. 链接程序
            GLES20.glLinkProgram(program)
            val linkStatus = IntArray(1)
            GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
            if (linkStatus[0] != GLES20.GL_TRUE) {
                val infoLog = GLES20.glGetProgramInfoLog(program)
                GLES20.glDeleteProgram(program)
                program = 0
                error("Program 链接失败:$infoLog")
            }

            // 获取 attribute 位置
            // 获取位置
            aPositionLocation = GLES20.glGetAttribLocation(program, "aPosition")
            aTexCoordLocation = GLES20.glGetAttribLocation(program, "aTexCoord")
            uTextureLocation = GLES20.glGetUniformLocation(program, "uTexture")

            // 加载纹理
            textureId = loadTexture(context, R.drawable.sample_image)
        } catch (error: Exception) {

            throw error
        } finally {
            GLES20.glDeleteShader(vertexShader)
            GLES20.glDeleteShader(fragmentShader)
        }
    }

    fun draw() {
        GLES20.glUseProgram(program)

        // 激活并绑定纹理
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
        GLES20.glUniform1i(uTextureLocation, 0)
        // 传入顶点数据
        GLES20.glEnableVertexAttribArray(aPositionLocation)
        GLES20.glVertexAttribPointer(aPositionLocation, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)

        // 传入纹理坐标数据
        GLES20.glEnableVertexAttribArray(aTexCoordLocation)
        GLES20.glVertexAttribPointer(aTexCoordLocation, 2, GLES20.GL_FLOAT, false, 0, textureBuffer)

        // 绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        // 清理
        GLES20.glDisableVertexAttribArray(aPositionLocation)
        GLES20.glDisableVertexAttribArray(aTexCoordLocation)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)
        GLES20.glUseProgram(0)
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        // 1. 创建着色器对象
        val shader = GLES20.glCreateShader(type)
        check(shader != 0) { "无法创建 OpenGL 着色器,type=$type" }
        // 2. 上传着色器源代码
        GLES20.glShaderSource(shader, shaderCode)
        // 3. 编译着色器
        GLES20.glCompileShader(shader)
        // 4. 检查编译状态
        val compileStatus = IntArray(1)
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
        if (compileStatus[0] != GLES20.GL_TRUE) {
            val infoLog = GLES20.glGetShaderInfoLog(shader)
            GLES20.glDeleteShader(shader)
            error("着色器编译失败,type=$type:$infoLog")
        }
        return shader
    }

    fun loadTexture(context: Context, resourceId: Int): Int {
        val textureIds = IntArray(1)

        // 1. 生成 1 个纹理 ID
        GLES20.glGenTextures(1, textureIds, 0)
        if (textureIds[0] == 0) {
            throw RuntimeException("Error generating texture")
        }

        // 2. 加载 Bitmap
        val options = BitmapFactory.Options().apply {
            inScaled = false  // 不缩放
        }
        val bitmap = BitmapFactory.decodeResource(
            context.resources,
            resourceId,
            options
        ) ?: throw RuntimeException("Error decoding bitmap")

        // 3. 绑定到 2D 目标
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIds[0])

        // 4. 设置纹理参数
        GLES20.glTexParameteri(
            GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MIN_FILTER,
            GLES20.GL_LINEAR
        )
        GLES20.glTexParameteri(
            GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MAG_FILTER,
            GLES20.GL_LINEAR
        )
        GLES20.glTexParameteri(
            GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_S,
            GLES20.GL_CLAMP_TO_EDGE
        )
        GLES20.glTexParameteri(
            GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_T,
            GLES20.GL_CLAMP_TO_EDGE
        )

        // 5. 上传纹理数据,数据路径是 `Java Bitmap → Native 内存 → glTexImage2D 拷贝到 GPU 显存`。
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)

        // 6. 回收 Bitmap
        bitmap.recycle()

        // 7. 解绑纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0)

        return textureIds[0]
    }
}
override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) {
    val texture = surfaceTexture ?: return
    // 此回调必须运行在拥有并已绑定 eglContext 的 GL 线程上。
    EGL14.eglMakeCurrent(eglDisplay, pbufferSurface, pbufferSurface, eglContext)
    texture.updateTexImage()
    texture.getTransformMatrix(transformMatrix)
    renderEncoderFrame(texture.timestamp) // 编码器
    EGL14.eglMakeCurrent(eglDisplay, previewEglSurface, previewEglSurface, eglContext)
    GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
    GLES20.glClearColor(0f, 0f, 0f, 1f)
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
    // Camera 画面
    oesDrawer.draw(
  		oesTextureId,
  		transformMatrix,
  		userTextureMatrix,
  		renderMvpMatrix,
  		swirlEffect,
  	)
    // 贴纸画面
    bitmapDrawer.draw()
    EGL14.eglSwapBuffers(eglDisplay, previewEglSurface)
}



private fun renderEncoderFrame(sourceTimestampNs: Long) {
    EGL14.eglMakeCurrent(eglDisplay, encoderEglSurface, encoderEglSurface, eglContext)
    GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
    GLES20.glClearColor(0f, 0f, 0f, 1f)
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
    // Camera 画面
    oesDrawer.draw(
  		oesTextureId,
  		transformMatrix,
  		userTextureMatrix,
  		renderMvpMatrix,
  		swirlEffect,
  	)
    // 贴纸画面
    bitmapDrawer.draw()
    EGLExt.eglPresentationTimeANDROID(
        eglDisplay,
        encoderEglSurface,
        sourceTimestampNs,
    )
    EGL14.eglSwapBuffers(eglDisplay, previewEglSurface)
}

预览后会发现贴图出现了,但相机画面不见了。这里是因为Bitmap的加载顺序是按照Android视图坐标系来的,也就是第一个话题说的,从左上角开始加载的,所以加载的bitmap是反的,y轴需要翻转一下,但是一般来说我们都是在着色器里面进行处理,不会让坐标进行翻转,因为坐标必须一一对应,有问题我们用矩阵。不过如果我们调用了 glReadPixels 读取像素,也要注意Y轴翻转问题,OpenGL 的 glReadPixels 是从屏幕/FBO的左下角开始读数据的,生成 Android 的 Bitmap 认为塞进来的数据是从左上角开始的。这里为了简便,我就直接写在着色器里面了。

    private val fragmentShaderCode = """
        precision mediump float;
        varying vec2 vTexCoord;
        uniform sampler2D uTexture;
        void main() {
            vec2 flippedCoord = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
            gl_FragColor = texture2D(uTexture, flippedCoord);
        }
    """.trimIndent()

类似地,glReadPixels 按 OpenGL 窗口坐标从左下区域读取,而 Android Bitmap 通常按顶部开始理解图像行,因此把读取结果转成 Bitmap 时常常还需要做一次 Y 翻转。

为什么只有贴图,没有摄像头画面

这里没有画面的原因是因为本身PNG 贴纸周围是透明像素(Alpha = 0),但在默认情况下,OpenGL 的 混合模式(Blending)是关闭的 。当关闭 Blend 时,贴纸绘制即便透明度为 0,也会把背景直接覆盖。如果贴纸铺了全屏(顶点坐标是 [−1,1][-1, 1]),没有开启混合就会把底层的相机彻底盖死。

// 1. 开启混合
GLES20.glEnable(GLES20.GL_BLEND);
// 2. 设置标准透明混合公式(源颜色 * 源Alpha + 目标颜色 * (1 - 源Alpha))
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);

// 3. 绘制贴纸
bitmapDrawer.draw();

// 4. 绘制完成后建议关闭(避免影响下一帧相机绘制)
GLES20.glDisable(GLES20.GL_BLEND);

开启混合后,相机与透明贴图可以正确叠加。接下来考虑双输出:同一帧既要显示到屏幕,又要送给编码器。

这里需要引入 EGLSurface。SurfaceView 能提供 Android Surface,MediaCodec.createInputSurface() 也能提供编码输入 Surface;它们都可以通过 eglCreateWindowSurface 包装成 EGL 的窗口 Surface。调用 eglMakeCurrent 后,当前 Context 的默认 Framebuffer 就对应这次绑定的 EGLSurface。

下面把三类常见 Surface 的创建方式并列展示。示例代码省略了 eglChooseConfig、错误检查和资源释放,实际工程中不能省略:

val eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
EGL14.eglInitialize(eglDisplay, version, 0, version, 1)

// for display
val uiSurface = surfaceHolder.surface
val previewEglSurface = EGL14.eglCreateWindowSurface(  
    eglDisplay, config, uiSurface, intArrayOf(EGL14.EGL_NONE), 0,  
)

// for codec
val inputSurface = codec.createInputSurface()
val encoderEglSurface = EGL14.eglCreateWindowSurface(  
    eglDisplay,  
    config,  
    inputSurface,  
    intArrayOf(EGL14.EGL_NONE),  
    0,  
)

// for offscreen current surface
val pbufferSurface = EGL14.eglCreatePbufferSurface(  
    eglDisplay, config, pbufferAttribs, 0,  
)

窗口 Surface 依赖原生窗口,而 Pbuffer 是 EGL 管理的离屏像素缓冲区,不关联可见窗口。它不是“没有 Surface”,而是没有原生窗口的 EGLSurface。考虑下面这个时序问题:

  • GL 线程启动了,相机也准备初始化了,需要提前创建 OES textureId 和编译 Shader。
  • 但此时 UI 层的 SurfaceView / TextureView 可能还没回调 surfaceCreated,底层的 ANativeWindow 根本不存在,你调不了 eglCreateWindowSurface。

此时可以创建一个很小的 Pbuffer 并通过 eglMakeCurrent 绑定,让 Context 在没有窗口 Surface 时仍拥有可用的绘制目标,从而完成 Shader 编译、纹理创建和 SurfaceTexture 初始化。它的目的不是“防止 EGLContext 被系统杀掉”,而是满足当前 EGL 配置下 eglMakeCurrent 对绘制/读取 Surface 的要求;若设备与配置支持 surfaceless context,也可以采用对应扩展能力。

EGLSurface 创建好后,主要通过两个操作驱动输出:eglMakeCurrent 选择当前绘制目标,eglSwapBuffers 提交窗口 Surface 的一帧。流程如下:

0. 初始化 GL 线程:eglMakeCurrent(pbufferSurface)

1. eglMakeCurrent(encoderSurface)  // 切到编码器
2. glViewport(编码器宽, 编码器高)
3. 绘制管线
4. 设置时间戳并 eglSwapBuffers(encoderSurface)

5. eglMakeCurrent(windowSurface)   // 切到屏幕
6. glViewport(屏幕宽, 屏幕高)
7. 绘制管线
8. eglSwapBuffers(windowSurface)

有些代码会在每次窗口绘制后主动切回 Pbuffer:

1. eglMakeCurrent(encoderSurface)  // 切到编码器
2. 绘制管线 & eglSwapBuffers
3. eglMakeCurrent(pbSurface)       // 
4. eglMakeCurrent(windowSurface)   // 切到屏幕
5. 绘制管线 & eglSwapBuffers
6. eglMakeCurrent(pbSurface)       // 

这种写法不是普遍必需的。频繁 eglMakeCurrent 会增加状态切换成本;是否切回 Pbuffer 应由线程模型和资源生命周期决定,而不是把它当成固定的“保险步骤”。

现在可以看出一个可优化点:相机、滤镜和贴纸合成分别在编码 Surface 与屏幕 Surface 上执行了一遍。输出目标越多、效果链越长,重复工作越明显。这正是引入 FBO 的合适时机。

引入 FBO:先合成,再分发

如果效果链里有多个 Drawer,我们希望昂贵的合成过程只执行一次。FBO 可以充当中间绘制目标:把结果写进挂载的 2D 纹理,随后再用简单的全屏拷贝 Pass,把这张纹理送往屏幕、编码器或截图流程。需要注意,FBO 自身不保存像素,真正保存像素的是它挂载的纹理或 Renderbuffer。

  • 不用 FBO 的惨状:

    • 切到编码器 Surface:画相机 -> 算贴纸位置 -> 画贴纸 -> swapBuffers。

    • 切到屏幕 Surface:再画一次相机 -> 再算一次贴纸位置 -> 再画一次贴纸 -> swapBuffers。

    • 每多加一个图层(美颜、LUT、贴纸、文字水印),两个输出目标都要重复执行这段效果链。

  • 引入 FBO 后的极简架构:

    1. 在 FBO 里一口气把:相机 -> 美颜 -> 贴纸/水印 组合画好(只画一次)。

    2. 此时 FBO 的那张 2D 纹理已经是一张合成好的完整成片。

    3. 切到编码器:把 FBO 纹理整张贴过去。

    4. 切到屏幕:把 FBO 纹理整张贴过去。

假设你有一个需求:“画面加黑白复古滤镜,但贴纸必须保持原本鲜艳的彩色,不能变黑白”。

  • 如果直接在屏幕上画:

    • 你必须在相机的片元着色器里实现黑白滤镜。

    • 然后在上方叠彩色贴纸。

  • 那如果需求反过来:“贴纸贴在人脸上,然后整体画面(包含贴纸)一起做光晕/泛光/色彩后处理”呢?

    • 如果没有 FBO 或其他可采样的中间渲染目标,就不能直接把“贴完贴纸后的合成图像”作为下一 Pass 的纹理输入。

    • 只有用 FBO 把贴纸和人脸先焊死在一张离屏纹理上,才能把这张合成纹理丢给后处理着色器做统一调色。

所谓离屏渲染(Off-screen Rendering),就是把渲染结果写到不可直接显示的目标。本文使用 FBO 挂载 2D 纹理来承接结果:多层画面先合成为一张普通 2D 纹理,之后再分发给屏幕、编码器或截图流程。

这并不意味着 FBO 一定更快:它会增加一次中间纹理写入和后续采样。只有当它减少了更昂贵的重复效果 Pass,或确实需要多阶段后处理时,收益才通常大于额外带宽成本。

引入 FBO 的实现流程可以分为三步:创建与装配 FBO、向 FBO 绘制合成、把 FBO 纹理分发到各个 Surface。

public final class FboHelper {
    private int fboId;
    private int fboTextureId;
    private int width;
    private int height;

    public void init(int width, int height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("FBO width and height must be greater than zero");
        }

        release();
        this.width = width;
        this.height = height;

        int[] textures = new int[1];
        GLES20.glGenTextures(1, textures, 0);
        fboTextureId = textures[0];
        if (fboTextureId == 0) {
            resetSize();
            throw new IllegalStateException("Failed to create the FBO color texture");
        }

        int[] framebuffers = new int[1];
        try {
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId);
            GLES20.glTexImage2D(
                GLES20.GL_TEXTURE_2D,
                0,
                GLES20.GL_RGBA,
                width,
                height,
                0,
                GLES20.GL_RGBA,
                GLES20.GL_UNSIGNED_BYTE,
                null
            );
            GLES20.glTexParameteri(
                GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MIN_FILTER,
                GLES20.GL_LINEAR
            );
            GLES20.glTexParameteri(
                GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_MAG_FILTER,
                GLES20.GL_LINEAR
            );
            GLES20.glTexParameteri(
                GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_S,
                GLES20.GL_CLAMP_TO_EDGE
            );
            GLES20.glTexParameteri(
                GLES20.GL_TEXTURE_2D,
                GLES20.GL_TEXTURE_WRAP_T,
                GLES20.GL_CLAMP_TO_EDGE
            );

            GLES20.glGenFramebuffers(1, framebuffers, 0);
            fboId = framebuffers[0];
            if (fboId == 0) {
                throw new IllegalStateException("Failed to create the framebuffer");
            }

            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId);
            GLES20.glFramebufferTexture2D(
                GLES20.GL_FRAMEBUFFER,
                GLES20.GL_COLOR_ATTACHMENT0,
                GLES20.GL_TEXTURE_2D,
                fboTextureId,
                0
            );

            int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER);
            if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
                throw new IllegalStateException(
                    "Framebuffer is incomplete, status=0x" + Integer.toHexString(status)
                );
            }
        } catch (RuntimeException error) {
            release();
            throw error;
        } finally {
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
            GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
        }
    }

    /** Subsequent draw calls write into {@link #getFboTextureId()}. */
    public void bind() {
        if (fboId == 0) {
            throw new IllegalStateException("FBO has not been initialized");
        }
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId);
        GLES20.glViewport(0, 0, width, height);
    }

    /** Restores drawing to the current EGL surface's default framebuffer. */
    public void unbind() {
        GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    }

    public int getFboId() {
        return fboId;
    }

    public int getFboTextureId() {
        return fboTextureId;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public void release() {
        if (fboId != 0) {
            GLES20.glDeleteFramebuffers(1, new int[]{fboId}, 0);
            fboId = 0;
        }
        if (fboTextureId != 0) {
            GLES20.glDeleteTextures(1, new int[]{fboTextureId}, 0);
            fboTextureId = 0;
        }
        resetSize();
    }

    private void resetSize() {
        width = 0;
        height = 0;
    }
}

第一步:创建 2D 颜色纹理

private int fboTextureId;

int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
fboTextureId = textures[0];
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId);

GLES20.glTexImage2D(
                GLES20.GL_TEXTURE_2D,
                0,
                GLES20.GL_RGBA,
                width,
                height,
                0,
                GLES20.GL_RGBA,
                GLES20.GL_UNSIGNED_BYTE,
                null
            );

第二步:创建 FBO 并挂载纹理

int[] framebuffers = new int[1];
GLES20.glGenFramebuffers(1, framebuffers, 0);
fboId = framebuffers[0];
// 绑定自定义 FBO;后续绘制不再写入当前 EGLSurface 的默认 Framebuffer
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId);

GLES20.glFramebufferTexture2D(
                GLES20.GL_FRAMEBUFFER,
                GLES20.GL_COLOR_ATTACHMENT0,
                GLES20.GL_TEXTURE_2D,
                fboTextureId,
                0
            );
// 挂载后,颜色输出会写入 fboTextureId 对应的纹理存储

glFramebufferTexture2D 把 2D 纹理挂到当前 FBO 的颜色附着点。之后的 Draw Call 仍然需要 Program、顶点输入等完整绘制状态,只是不需要把这张“输出纹理”同时作为当前 Pass 的采样输入。完成挂载后应调用 glCheckFramebufferStatus 检查完整性。

其实这里就可以说明一点,首先都需要创建纹理id,但是有一点不一样的是,FBO不需要纹理单元和着色器里面那些逻辑,就只需要创建一个FrameBuffers,然后和纹理id产生对应关系就行,其实仔细看API他们有一些异曲同工之妙的。最后一个glFramebufferTexture2D的意思是:当缓冲区被绑定到 fboId 了,这个时候因为状态机模式,所以当前接下来的画图操作全都是在针对 fboId 这个缓冲区句柄发号施令。然后 glFramebufferTexture2D 是给当前 FBO 的 颜色附着点 插上 fboTextureId,从而保证了 fboTextureId 才是最终真正存储这些画面像素数据的句柄对象。那么有一个问题?我们只如果将这里的fboTextureId 从2D 换成OES纹理id会怎样呢,如果你尝试这样做,glCheckFramebufferStatus 会返回 GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 错误,渲染也会直接黑屏。OES纹理的一个特殊性:OES 纹理是“只读(Read-Only)”的。

External OES 纹理面向外部图像流采样,要把相机 OES 内容变成后续可重复采样的 2D 纹理,常见做法正是:以 OES 为输入,绘制到挂载了 2D 纹理的 FBO。

第三步:绘制到 FBO,再输出到 Surface

// 1. 绑定自定义 FBO:切换离屏绘制目标
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId);

GLES20.glViewport(0, 0, fbowidth, fboheight);
GLES20.glClearColor(0f, 0f, 0f, 1f)  
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
oesDrawer.draw(  
    oesTextureId,  
    transformMatrix,  
    userTextureMatrix,  
    renderMvpMatrix,  
    swirlEffect,  
)
GLES20.glEnable(GLES20.GL_BLEND);
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
bitmapDrawer.draw()
GLES20.glDisable(GLES20.GL_BLEND)


// 2. 回到当前 EGLSurface 的默认 Framebuffer
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);

这里会出现一个新问题:FBO 已经画完,但屏幕仍然没有内容。glBindFramebuffer(..., 0) 只是在切换绘制目标,不会自动把离屏结果复制到默认 Framebuffer。接下来需要再发出一次简单 Draw Call,把 fboTextureId 当作普通 2D 输入纹理绘制到窗口 Surface。

// (接上面的代码)
EGL14.eglMakeCurrent(  
    eglDisplay, previewEglSurface, previewEglSurface, eglContext)
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)  
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)  
GLES20.glClearColor(0f, 0f, 0f, 1f)  
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)  
// FBO 的普通 2D 纹理 -> 预览窗口默认 framebuffer。  
texture2dDrawer.draw(fboHelper.fboTextureId)
EGL14.eglSwapBuffers(eglDisplay, previewEglSurface)

同一张合成纹理也可以继续分发到多个 Surface:

int combinedTextureId = fboHelper.getFboTextureId();

// ========= 1. 输出给录制编码器 =========
eglMakeCurrent(mEglDisplay, mEncoderSurface, mEncoderSurface, mEglContext);
GLES20.glViewport(0, 0, encodeWidth, encodeHeight);
normal2DDrawer.draw(combinedTextureId);
EGLExt.eglPresentationTimeANDROID(mEglDisplay, mEncoderSurface, surfaceTexture.getTimestamp());
EGL14.eglSwapBuffers(mEglDisplay, mEncoderSurface);

// ========= 2. 输出给屏幕预览 =========
eglMakeCurrent(mEglDisplay, mWindowSurface, mWindowSurface, mEglContext);
GLES20.glViewport(0, 0, screenWidth, screenHeight);
normal2DDrawer.draw(combinedTextureId);
EGL14.eglSwapBuffers(mEglDisplay, mWindowSurface);

本节小结

  • EGLSurface 是 Context 的绘制目标接口:窗口 Surface 可对应屏幕或编码器,Pbuffer 则是不依赖原生窗口的离屏 Surface。
  • FBO 是 OpenGL 内部的附件集合,本身不存像素;挂载的纹理或 Renderbuffer 才承载数据。
  • OES 纹理适合作为相机/解码流输入,普通 2D 纹理适合作为 FBO 输出和后续 Pass 输入。
  • glBindFramebuffer(..., 0) 只切换回当前 EGLSurface 的默认 Framebuffer,不会自动复制 FBO 内容。
  • 先把复杂效果链合成到 FBO,再把结果分发给屏幕和编码器,可以减少重复计算;代价是增加一次中间写入与采样。

一句话记忆:Pbuffer 解决“没有窗口时 Context 画到哪里”,FBO 解决“这一轮 Draw Call 的结果写到哪个附件”。


到这里,我们已经把 Android 相机渲染中最容易混在一起的几个对象串成一条链:

Camera / Codec → SurfaceTexture → External OES → Shader → FBO + 2D Texture → Window EGLSurface / Encoder EGLSurface

下一篇我们讨论如何自定义GlThread以及什么是共享纹理,以及VBO,VAO这些概念。