13、OpenGL ES渲染视频:将视频帧渲染到OpenGL纹理、实现滤镜效果(黑白、怀旧)
各位同学,欢迎来到第13章。前面我们讲了怎么用MediaCodec解码,怎么把数据塞给SurfaceView。但说实话,SurfaceView能做的事太有限了——你没法在它上面加滤镜,没法做特效,没法搞那些酷炫的转场。那怎么办?
答案就是:把视频帧交给OpenGL ES。让GPU来接管渲染。
我个人习惯把OpenGL ES比作一个画板。你解码出来的每一帧画面,就是一张贴纸。你可以把贴纸贴在画板上,也可以在上面涂颜色、做扭曲、加滤镜。说白了,一切皆纹理。
13.1 为什么非要用OpenGL ES?
你可能会问:我直接用SurfaceView渲染不香吗?嗯,香是香,但不够灵活。SurfaceView背后是一个独立的窗口,你没法在它上面叠加其他图形。而OpenGL ES不一样——它让你完全控制每一个像素。
我在项目中遇到过这样一个需求:用户希望在看视频的同时,画面边缘有一圈模糊的晕影效果。用SurfaceView?做不到。用OpenGL ES?十分钟搞定。
核心思路:解码器输出一帧YUV数据,我们把它上传到OpenGL纹理,然后通过自定义着色器(Shader)来控制每个像素的最终颜色。
13.2 视频帧如何变成纹理?
这一步很关键。解码器出来的数据通常是YUV格式,而OpenGL吃的是RGB纹理。所以我们需要做一次转换。
我常用的做法是:
- 从MediaCodec拿到SurfaceTexture(或者直接拿ByteBuffer)
- 把YUV数据上传到OpenGL纹理
- 在片段着色器里做YUV到RGB的转换
这里贴一段核心代码,展示如何把YUV帧绑定到纹理:
// 创建OpenGL纹理
GLuint textureId;
glGenTextures(1, &textureId);
glBindTexture(GL_TEXTURE_2D, textureId);
// 设置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 上传YUV数据(这里假设你已经有了Y、U、V三个平面的数据)
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, width, height, 0,
GL_LUMINANCE, GL_UNSIGNED_BYTE, yPlane);
// 注意:实际项目中需要分别上传U、V平面,或者使用GL_RGBA格式统一上传
小技巧:如果你用的是SurfaceTexture,可以直接调用updateTexImage()来更新纹理,省去手动上传的麻烦。但要注意线程同步问题。
13.3 着色器:滤镜的灵魂
纹理上传好了,接下来就是着色器的事了。OpenGL ES的渲染管线里,片段着色器(Fragment Shader)负责决定每个像素最终显示什么颜色。
说白了,滤镜就是改颜色。黑白滤镜就是把RGB转成灰度值,怀旧滤镜就是加一层棕色调。
13.3.1 黑白滤镜
黑白滤镜的原理很简单:把每个像素的R、G、B三个通道加权平均,得到一个灰度值,然后把这个灰度值赋给R、G、B三个通道。
// 片段着色器 - 黑白滤镜
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
gl_FragColor = vec4(gray, gray, gray, color.a);
}
注意这里的权重系数。0.299、0.587、0.114是标准的亮度计算公式。我曾经见过有人直接用三个通道取平均,结果画面发灰,暗部细节全丢了。嗯,这里要注意。
13.3.2 怀旧滤镜
怀旧滤镜稍微复杂一点。它本质上是在灰度图的基础上,叠加一层棕色调。我习惯的做法是:先转灰度,再对RGB通道分别做偏移。
// 片段着色器 - 怀旧滤镜
precision mediump float;
varying vec2 vTexCoord;
uniform sampler2D uTexture;
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float gray = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
float r = gray * 1.2;
float g = gray * 0.9;
float b = gray * 0.5;
gl_FragColor = vec4(clamp(r, 0.0, 1.0),
clamp(g, 0.0, 1.0),
clamp(b, 0.0, 1.0),
color.a);
}
你想想看,为什么R通道乘1.2,B通道乘0.5?因为怀旧色调偏暖黄,红色多一点,蓝色少一点。这个比例你可以自己调,调出你喜欢的复古感。
避坑指南:我曾经在项目里直接用了乘系数不加clamp,结果高亮区域红色溢出,画面一片惨白。记得一定要用clamp把值限制在0.0到1.0之间。
13.4 整体渲染流程
把上面这些串起来,整个渲染流程是这样的:
这个图你看懂了吗?核心就是:解码 → 纹理上传 → 着色器处理 → 输出。滤镜切换发生在着色器这一步,我们可以在运行时动态替换片段着色器的源码。
13.5 性能优化:别让GPU闲着
在实际项目中,性能是个大问题。尤其是低端机,GPU能力有限。我踩过几个坑,分享给你:
- 纹理尺寸要对齐:OpenGL ES要求纹理宽高是2的幂次?那是老黄历了。现在大部分设备支持非2幂次纹理,但最好还是保持16字节对齐,否则会有性能损失。
- 减少纹理上传次数:每次glTexImage2D都很贵。如果帧率是30fps,一秒就要上传30次。我建议用PBO(Pixel Buffer Object)来异步上传,或者直接用SurfaceTexture的零拷贝机制。
- 着色器编译要缓存:每次切换滤镜都重新编译着色器?太慢了。我习惯在初始化时把所有滤镜的着色器都编译好,运行时直接切换program对象。
我的经验:在小米6上测试,用SurfaceTexture方式比手动上传ByteBuffer快了将近3倍。能走零拷贝就别走内存拷贝。
13.6 完整代码结构
最后,给你一个完整的代码骨架。实际项目中,我会把渲染器封装成一个类:
public class VideoRenderer {
private int program; // 当前滤镜的着色器程序
private int textureId; // 视频帧纹理
private int vbo; // 顶点缓冲区
private int ebo; // 索引缓冲区
// 初始化:创建纹理、编译着色器、设置顶点数据
public void init() { ... }
// 渲染一帧:绑定纹理、使用着色器、绘制
public void renderFrame(MediaCodec codec) {
// 1. 更新纹理
// 2. 使用当前滤镜的program
// 3. 绘制全屏四边形
// 4. 交换缓冲区
}
// 切换滤镜:传入滤镜类型,切换program
public void switchFilter(FilterType type) {
// 根据type选择对应的片段着色器源码
// 重新编译链接program
}
}
嗯,到这里,你已经掌握了OpenGL ES渲染视频帧的核心技术。黑白滤镜和怀旧滤镜只是开胃菜,你完全可以自己写出更多效果——比如马赛克、边缘检测、甚至人脸美颜。原理都一样:控制每个像素的颜色。
记住一句话:在GPU面前,没有什么是改不了颜色的。如果有,那就再写一个着色器。
本章要点回顾:
- 视频帧通过纹理上传到OpenGL ES
- 片段着色器控制每个像素的最终颜色
- 黑白滤镜 = 加权平均转灰度
- 怀旧滤镜 = 灰度 + 色调偏移
- 性能优化:零拷贝纹理、着色器缓存、PBO异步上传