从 Android 开发者的视角,串起 OpenGL ES、坐标系、Shader、纹理、EGL、FBO 与离屏渲染。
还记得第一次搞OpenGL的时候是2019年,那个时候有个需求就是要在相机上加特效,并且录制成一个MP4,当时也是各种开源框架代码抄,虽然过程中也学习了很多,但是一直并没有写一些能够去反哺的知识,所以最近花时间写了一些文章,这篇是开篇,后面还有2篇。
目前现有的很多文章都会从数学或完整图形管线讲起,但 Android 开发者真正容易卡住的,往往是另一组问题:GL线程是什么,SurfaceTexture 为什么对应 OES 纹理?顶点坐标、纹理坐标和 glViewport 到底各管什么?FBO 是什么?
这篇文章不追求一次讲完所有图形学知识,而是围绕四个能直接落到相机预览、贴纸、水印和录制场景的问题展开。
目录
- [1. OpenGL、Skia 与 GPU 是什么关系](#1. OpenGL、Skia 与 GPU 是什么关系 "#1-openglskia-%E4%B8%8E-gpu-%E6%98%AF%E4%BB%80%E4%B9%88%E5%85%B3%E7%B3%BB")
- [2. 坐标系与着色器如何配合](#2. 坐标系与着色器如何配合 "#2-%E5%9D%90%E6%A0%87%E7%B3%BB%E4%B8%8E%E7%9D%80%E8%89%B2%E5%99%A8%E5%A6%82%E4%BD%95%E9%85%8D%E5%90%88")
- [3. 状态机、纹理与采样器是什么关系](#3. 状态机、纹理与采样器是什么关系 "#3-%E7%8A%B6%E6%80%81%E6%9C%BA%E7%BA%B9%E7%90%86%E4%B8%8E%E9%87%87%E6%A0%B7%E5%99%A8%E6%98%AF%E4%BB%80%E4%B9%88%E5%85%B3%E7%B3%BB")
- [4. 离屏渲染、FBO 与 Pbuffer](#4. 离屏渲染、FBO 与 Pbuffer "#4-%E7%A6%BB%E5%B1%8F%E6%B8%B2%E6%9F%93fbo-%E4%B8%8E-pbuffer")
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 中可以看到如下文件:
vbnet
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 硬件的寄存器中。

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对应,是因为如果位置不对,我们应该是引入矩阵来进行处理。
kotlin
// 铺满全屏的顶点坐标
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上执行的。 主程序会把数据传给着色器,例如顶点数据,着色器代码就能够处理这些数据,并将结果返回给主程序。
- 在 Vertex shader 中 main 函数中最终得到的结果是顶点坐标 gl_Position (顶点位置,类似于图钉)
- 在 Fragment shader 中 main 函数中计算得到的最终结果是像素点的颜色值 gl_FragColor (每个像素的颜色)
着色器虽然里面有很多渲染管线的知识,但是我们一般来说只需要懂得什么是顶点和片元着色器,我们把着色器理解为一个画笔,对,就是Android中Canvas的Paint,那么坐标系就是决定了首先我们画框(顶点坐标)有多大,一般都是全屏,而画布(纹理坐标)决定了我们画布和画框之间到底要不要完全一致,一般来说都是一样的。具体的流程可以想象一下,当我们调用OpenGL去画一张1920x1080大小的红色矩形,他的一个的完整流水线:
-
顶点着色器 (Vertex Shader) ------ 确定画框的四个角
-
对象是"顶点",不是像素: 在当前四顶点矩形的示例中,顶点着色器执行 4 次。
-
行为: 它接收这 4 个角的坐标,输出 4 个最终的位置
gl_Position。同时,它把这 4 个角对应的纹理坐标vTexCoord记录下来,准备传给下一步。此时,屏幕上什么都没有,只有虚空中的四个点。
-
-
图元装配与光栅化 (Rasterization) ------ 连线并砸成像素(关键转折点)
-
连线: GPU 拿到这 4 个点,把它们连成两个三角形(因为 GPU 只会画三角形)。
-
光栅化(填格子): GPU 计算出这两个三角形在 1920x1080 的屏幕上,到底覆盖了哪 2,073,600 个像素(1920 * 1080)。
-
自动插值: 它知道左上角
vTexCoord是 (0,1),右上角是 (1,1),因此能推算三角形内部各片元对应的纹理坐标。顶点着色器输出的varying正是在这一阶段被自动插值的。
-
-
片段/片元着色器 (Fragment Shader) ------ 疯狂上色
-
对象是"片元": 对于覆盖全屏的图元,片元着色器通常会产生与像素规模相当的大量调用;多重采样、裁剪、深度测试等因素会让实际次数与物理像素数不完全相等。
-
行为: 每个像素在执行时,都会收到光栅化阶段发给它的专属、插值后的
vTexCoord。 -
结果:
-
如果你只是想画一个纯红色的矩形 ,这里直接写死输出
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);即可,不需要纹理。 -
如果你想贴一张图 ,片段着色器就会拿着这个专属的
vTexCoord,调用texture2D去图片对应的位置取颜色,然后赋值给gl_FragColor。
-
-
-
测试与混合 (Testing & Blending) ------ 最终输出
- GPU 检查这些红色的像素有没有被其他东西挡住(深度测试),或者需不需要半透明叠加(混合),最后写入帧缓冲区(FrameBuffer),显示在你的屏幕上。
顶点着色器
kotlin
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()
片元着色器
kotlin
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,那么整个屏幕的每个像素都会被 GPU 着色。
- 如果
-
aTexCoord(放大镜 / 采样范围) :决定了从原图中截取哪一部分塞进画框。-
UV 是 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:9,但手机屏幕是 20:9。
如果什么都不做,直接把相机贴到全屏画框上,画面就会被上下拉长变形(人脸变长)。此时有三种行业标准的对齐方案:
方案 A:等比例居中,留黑边(FitCenter / Letterbox)
-
做法 :
glViewport依然设为全屏,但通过矩阵把aPosition(画框)在高度上压窄一点。 -
效果:画面比例完美保真,屏幕上下留黑边。
方案 B:等比例铺满,裁剪溢出边缘(CenterCrop)
-
做法 :
aPosition依然铺满全屏,但通过纹理矩阵对aTexCoord进行缩放,只截取中间 20:9 的部分画面。 -
效果:全屏无黑边,但相机的左右或上下两边会被切掉一小块。
方案 C:直接改动 Viewport 窗口
-
做法 :计算居中的视口区域,只把视口开在屏幕中间,例如
glViewport(0, 240, 1080, 1920)。 -
效果:OpenGL 只向中间区域绘制,视口外面的区域自然保留清屏背景色(黑色)。
本节小结
- 顶点坐标决定"画到哪里",纹理坐标决定"从哪里取样",
glViewport决定 NDC 如何映射到当前输出目标的像素区域。 - 顶点着色器先处理少量顶点,光栅化阶段生成片元并插值纹理坐标,片元着色器再完成采样与着色。
- 缩小顶点范围会缩小画框;缩小纹理坐标范围会裁取局部并放大到画框。
- 比例不一致时,应明确选择拉伸、FitCenter 或 CenterCrop,不能期待 OpenGL 自动判断业务意图。
一句话记忆:顶点坐标管"画框",纹理坐标管"取哪块图",Viewport 管"这幅画最终落在哪些像素上"。
如果想继续补渲染管线的直觉,可以再找可视化资料辅助理解;关于渲染管线的知识,我推荐大家去看一个B站视频,叫做上帝视角看GPU。我这里就简单做个扫盲就行了。
3. 状态机、纹理与采样器是什么关系
状态机模式到底是什么
很多文章把状态机解释成"上一步会影响下一步",但这还不够准确。更实用的理解是:一个 OpenGL Context 保存着一组当前状态,后续绘制命令会读取这些状态。 它不是整个应用只有一个全局对象,而是每个 Context 都有自己的状态集合。
先看我们更熟悉的面向对象写法:
scss
// 面向对象的方式:状态归属于具体的 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 的某个状态槽位,再发出绘制命令。例如:
kotlin
// 当前激活的纹理单元是 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。
scss
// 假设整个系统只能用这一个 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 中汇合。
kotlin
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 后不能继续沿用旧位置。
ini
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 相机链路中最常见的两种:
GL_TEXTURE_EXTERNAL_OES:接收SurfaceTexture对应的外部图像流。GL_TEXTURE_2D:普通二维纹理,可上传 Bitmap,也可作为 FBO 的颜色附件。
kotlin
//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。
java
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() 后重新获取并用于采样坐标。
纹理对象的常见准备流程可以概括为三步:
- 用
glGenTextures生成纹理对象名称。 - 绑定到纹理目标,并设置过滤、环绕等参数。
- 为普通纹理分配或上传图像;External OES 纹理则由外部图像流提供内容。
纹理单元和采样器
kotlin
// 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 具有两个相关的初始状态:
GL_TEXTURE0是状态机默认激活的单元。- 着色器里的
uniform int默认值就是0。
即便如此,工程代码仍建议显式设置 glActiveTexture 和 sampler Uniform,避免逻辑依赖隐含默认值。
0 号之外还有更多纹理单元。当一个 Shader 同时需要底图、LUT、蒙版、噪声图或贴纸时,就会把不同纹理绑定到不同单元,再让各个 sampler 指向对应编号。
- 着色器端(OpenGL ES 2.0 Fragment Shader)
glsl
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
);
}
- 初始化阶段:配置 Uniform 绑定槽位
kotlin
GLES20.glUseProgram(shaderProgram)
// 告诉 textureWall 对应 0 号单元,textureFace 对应 1 号单元
// 这个赋值只需在初始化或切换着色器时设置一次,不需要每帧都调
GLES20.glUniform1i(GLES20.glGetUniformLocation(shaderProgram, "textureWall"), 0)
GLES20.glUniform1i(GLES20.glGetUniformLocation(shaderProgram, "textureFace"), 1)
- 渲染循环
kotlin
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 场景中非常常见。 总结下来就是:
scss
+--------------------+ 插卡 +----------------------+
| 纹理数据 (textureID) | -------------> | 纹理单元 (GL_TEXTURE0) |
+--------------------+ glBindTexture +----------------------+
▲
| 采样数据
|
+----------------------+
| sampler2D 变量 (值 = 0)| (着色器内)
+----------------------+
▲
| 设置监听槽位
glUniform1i(location, 0)
对当前文章的采样场景来说,GL_TEXTURE_2D 和 GL_TEXTURE_EXTERNAL_OES 都可以作为 Shader 的图像输入,但它们是不同的纹理目标,支持的操作和 sampler 类型也不同,不能混用。
到这里,纹理 ID、纹理目标、纹理单元和 Shader 中的 sampler 就串起来了。
回到 draw(),下面几行分别提交顶点属性,并把 SurfaceTexture 的纹理变换矩阵写入 Uniform:
kotlin
// 传入顶点数据
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)
到这里,完整绘制流程可以整理为:
- 定义顶点坐标和纹理坐标;需要旋转、裁剪或翻转时使用矩阵处理。相机流应使用
SurfaceTexture.getTransformMatrix()返回的 4 × 4 矩阵。 - 把顶点数据放入 Direct
ByteBuffer,或进一步上传到 VBO。 - 编写顶点着色器和片元着色器,声明
attribute、uniform、varying等输入输出。 - 编译 Shader、链接 Program,并检查编译与链接状态。
- 创建纹理对象,绑定纹理目标与纹理单元,再让 sampler 指向对应的单元编号。
- 启用顶点属性并提交顶点、纹理坐标,设置矩阵等 Uniform。
- 调用
glDrawArrays或glDrawElements发出绘制命令。 - 按渲染器约定恢复或解绑会影响后续流程的状态。
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:直接添加贴图
kotlin
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]
}
}
kotlin
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 认为塞进来的数据是从左上角开始的。这里为了简便,我就直接写在着色器里面了。
ini
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),没有开启混合就会把底层的相机彻底盖死。
kotlin
// 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、错误检查和资源释放,实际工程中不能省略:
kotlin
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 的一帧。流程如下:
text
0. 初始化 GL 线程:eglMakeCurrent(pbufferSurface)
1. eglMakeCurrent(encoderSurface) // 切到编码器
2. glViewport(编码器宽, 编码器高)
3. 绘制管线
4. 设置时间戳并 eglSwapBuffers(encoderSurface)
5. eglMakeCurrent(windowSurface) // 切到屏幕
6. glViewport(屏幕宽, 屏幕高)
7. 绘制管线
8. eglSwapBuffers(windowSurface)
有些代码会在每次窗口绘制后主动切回 Pbuffer:
text
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 后的极简架构:
-
在 FBO 里一口气把:
相机->美颜->贴纸/水印组合画好(只画一次)。 -
此时 FBO 的那张 2D 纹理已经是一张合成好的完整成片。
-
切到编码器:把 FBO 纹理整张贴过去。
-
切到屏幕:把 FBO 纹理整张贴过去。
-
假设你有一个需求:"画面加黑白复古滤镜,但贴纸必须保持原本鲜艳的彩色,不能变黑白"。
-
如果直接在屏幕上画:
-
你必须在相机的片元着色器里实现黑白滤镜。
-
然后在上方叠彩色贴纸。
-
-
那如果需求反过来:"贴纸贴在人脸上,然后整体画面(包含贴纸)一起做光晕/泛光/色彩后处理"呢?
-
如果没有 FBO 或其他可采样的中间渲染目标,就不能直接把"贴完贴纸后的合成图像"作为下一 Pass 的纹理输入。
-
只有用 FBO 把贴纸和人脸先焊死在一张离屏纹理上,才能把这张合成纹理丢给后处理着色器做统一调色。
-
所谓离屏渲染(Off-screen Rendering),就是把渲染结果写到不可直接显示的目标。本文使用 FBO 挂载 2D 纹理来承接结果:多层画面先合成为一张普通 2D 纹理,之后再分发给屏幕、编码器或截图流程。
这并不意味着 FBO 一定更快:它会增加一次中间纹理写入和后续采样。只有当它减少了更昂贵的重复效果 Pass,或确实需要多阶段后处理时,收益才通常大于额外带宽成本。
引入 FBO 的实现流程可以分为三步:创建与装配 FBO 、向 FBO 绘制合成 、把 FBO 纹理分发到各个 Surface。
java
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 颜色纹理
java
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 并挂载纹理
java
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
kotlin
// 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。
kotlin
// (接上面的代码)
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:
java
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这些概念。