给视频播放器装上"效果引擎":ExoPlayer 的 OpenGL 滤镜链路,从原理到落地
【免费下载链接】ExoPlayerAn extensible media player for Android项目地址: https://gitcode.com/gh_mirrors/exop/ExoPlayer
做视频播放器的时候,我几乎每次都会被问到同一个需求:能不能给画面加一层滤镜?灰度、复古、调亮度,像短视频 App 那样随手一滑就换效果。原生MediaPlayer在这件事上基本是条死路——它把渲染完全封装死了,我们插不进任何一步。而 ExoPlayer 作为 Google 开源的 Android 可扩展媒体播放器,恰好把渲染链路的"最后一公里"开放了出来。这篇文章会带你走一遍它背后的 OpenGL 处理管线,并给出一个能直接跑通的自定义滤镜,让"给视频变脸"这件事从不可为变成半小时就能做完。
先拆一拆:为什么"给画面加滤镜"这么难
表面上看,滤镜只是逐像素改颜色,但在 Android 上它横着三道坎。第一道,解码器输出的视频帧并不是一张我们能随手改的 Bitmap,而是 GPU 上的纹理(Texture),它走的是底层图形 API,不是 Java 层能碰的东西。第二道,视频是持续渲染的,每秒 30 帧意味着你的处理逻辑必须挂在渲染循环里,任何卡顿都会直接变成画面丢帧。第三道,播放、解码、渲染往往分属不同线程,处理效果的人和消费帧的人得有一套同步机制。
所以问题的本质是:我们要在"帧从解码器出来、还没到屏幕"的这段路上,插进一个自定义的加工步骤。ExoPlayer 的 effect 模块就是为这个场景准备的。
先看清画面是怎么到达屏幕的
在进入代码之前,值得花两分钟搞清楚 ExoPlayer 的渲染链路上有哪些环节。视频流经解码器(MediaCodec)之后,会进入一个可替换的渲染组件;默认情况下它直接把帧画到一个Surface上,这个 Surface 通常就是播放器界面的画布。而当我们引入VideoFrameProcessor时,事情就变成了这样:
解码器 → OpenGL 纹理 → 效果链(逐帧处理)→ 输出 Surface → 屏幕
VideoFrameProcessor在一个后台线程上管理 OpenGL 上下文,负责按顺序执行一串效果,再把结果交给显示层。它的实现藏在 library/effect/src/main/java/com/google/android/exoplayer2/effect/DefaultVideoFrameProcessor.java,而 demos/gl 目录下的示例应用则展示了如何用一块GLSurfaceView把这条链路接进真实播放流程。
明白这条链路之后,我们只需要回答一个问题:效果本身长什么样,怎么写?
一个"效果"到底长什么样
ExoPlayer 的效果体系围绕两个接口展开。GlEffect是效果的"说明书",它不碰任何 GL 细节,只负责一件事——在需要时把自己转换成实际的着色器程序:
library/effect/src/main/java/com/google/android/exoplayer2/effect/GlEffect.java 里定义了一个核心方法toGlShaderProgram(Context, boolean useHdr),第二个参数useHdr直接决定了你的颜色计算是在 HDR 还是 SDR 色彩空间里进行,这个细节后面还会踩到。
而GlShaderProgram才是真正干活的那个,它承载 GLSL 着色器,负责纹理的加载、绘制与释放。两者分离的好处很明显:一个效果对象可以被多个播放器实例共享,且描述(我要灰度)与实现(怎么算灰度)可以独立演化。
更妙的是,框架里已经内置了一条捷径——RgbMatrix接口。只要你的滤镜本质上是一个"颜色变换矩阵",连着色器都不用自己写,框架会用DefaultShaderProgram帮你把矩阵灌进片段着色器,library/effect/src/main/java/com/google/android/exoplayer2/effect/RgbMatrix.java 里已经写好了这个默认实现。
先别急着写 GLSL:官方轮子够用吗
在动手之前,我建议先看一眼官方的成品。RgbFilter提供了现成的灰度与反色滤镜,构造方式十分直白:
// 灰度 + 反色,两个效果可以放进同一个列表 GlEffect grayscale = RgbFilter.createGrayscaleFilter(); GlEffect inverted = RgbFilter.createInvertedFilter();这段代码做了什么:用工厂方法拿到两个官方内置的颜色滤镜对象,它们可以直接作为"效果链"的成员参与渲染。
官方实现里灰度矩阵的系数是有讲究的——SDR 用 BT.709 的亮度系数,HDR 用 BT.2020 的系数,library/effect/src/main/java/com/google/android/exoplayer2/effect/RgbFilter.java 里两种矩阵都备好了。这意味着同一个效果对象在播放普通视频和 HDR 视频时,会自动切换合适的色彩变换,无需你操心。这就是"效果描述与实现分离"设计带来的实际收益。
轮子不够用时,自己造一个也不难
假设我们要一个官网上没有的"复古棕褐"效果。基于RgbMatrix,只需要提供一组 4×4 的颜色矩阵:
public final class SepiaEffect implements RgbMatrix { // 列主序存储,三行分别是 R/G/B 的加权系数 private static final float[] SEPIA_MATRIX = { 0.393f, 0.349f, 0.272f, 0f, 0.769f, 0.686f, 0.534f, 0f, 0.189f, 0.168f, 0.131f, 0f, 0f, 0f, 0f, 1f }; @Override public float[] getMatrix(long presentationTimeUs, boolean useHdr) { return SEPIA_MATRIX; } }这段代码做了什么:定义了一个颜色变换矩阵,让每个像素的 R 通道从三个原通道按 0.393/0.769/0.189 加权而来,G、B 同理,这就是经典棕褐色滤镜的数学本质。
因为RgbMatrix自带默认的着色器实现,这个类写完就能直接用。如果你想要更灵活的效果(比如九宫格分屏、浮雕、水印叠加),那就需要直接实现GlEffect并提供一个自定义着色器片段。demos/gl 里的水印叠加示例就是个很好的参照,它在片段着色器中同时采样视频纹理和水印纹理,动态叠加画面:demos/gl/src/main/assets/bitmap_overlay_video_processor_fragment.glsl。帧率水印这类效果,本质上就是把uTexSampler1的采样结果按坐标混入主画面。
下面这张测试图来自项目自带的回归数据,可以直观感受同一帧画面经滤镜处理前后的差异:原始帧细节完整、色彩自然,而叠加了灰度与红色通道增强后,画面呈现鲜明的调色风格——这正是效果链逐帧作用的结果。
效果写好了,往哪接?两条落地路径
滤镜做好之后,实际使用有两条截然不同的路线,取决于你要的是"转码"还是"预览"。
路线一:离线转码。如果需求是导出成品视频(比如分享出去的视频),应该用Transformer,它复用同一套GlEffect体系,把效果链应用到整段视频再输出 MP4。项目文档 docs/transforming-media.md 给出了完整的用法:
Transformer transformer = new Transformer.Builder(context) .setVideoEffects(List.of(new SepiaEffect())) .addListener(transformerListener) .build(); transformer.startTransformation(inputMediaItem, outputPath);这段代码做了什么:构建一个转码器实例,把自定义的棕褐滤镜注入视频处理管线,然后启动异步转码,把处理后的视频写入指定路径。
路线二:播放中实时预览。如果滤镜要跟着播放实时切换,就需要把渲染管线改成GLSurfaceView方案——ExoPlayer 解码后的帧不直接上屏,而是作为纹理送进你的 GL 绘制循环。demos/gl 的MainActivity就是这么干的:demos/gl/src/main/java/com/google/android/exoplayer2/gldemo/MainActivity.java,它把自定义的VideoProcessor挂到GLSurfaceView上,播放器照常工作,画面却在每次draw回调里被加工过。切换效果时,只要替换效果对象并刷新渲染即可,播放进度不受影响。
两个值得提前踩平的坑
第一个坑是色彩空间。toGlShaderProgram里的useHdr参数不是摆设:处理 HDR 源时,帧内颜色是线性 RGB BT.2020,如果你的着色器按 SDR 的 BT.709 系数硬算,出来的颜色会完全不对。所以自写滤镜时,要么参考RgbFilter的做法同时准备两套参数,要么明确你的输入源不会切到 HDR。
第二个坑是资源回收。GlShaderProgram的输出纹理如果不及时释放,播放几分钟后内存会肉眼可见地涨。框架里BaseGlShaderProgram已经封装了纹理池复用,自定义实现时务必走它的生命周期方法,不要自己裸调glGenTextures又忘了glDeleteTextures。
下一步还能做什么
到这里,我们已经走完了一个完整闭环:从理解渲染链路,到写一个可用的自定义滤镜,再到选对落地路径。这条路再往前走,空间其实很大——可以把效果链按时间轴动态切换,做出"前半段灰度、后半段彩色"的分段滤镜;可以把多个效果合并进同一个着色器,把绘制次数降到一次;也可以研究OverlayEffect与Presentation,把贴纸、字幕、画面比例调整统统纳入同一套效果体系。顺带一提,这个仓库的代码已经标注了向 AndroidX media3 迁移的方向,本文讲的效果接口体系在新版本里依然成立,迁移成本远比你想象的低。下一次打开播放器时,不妨想想:眼前这帧画面,还能被改写成什么样子。
【免费下载链接】ExoPlayerAn extensible media player for Android项目地址: https://gitcode.com/gh_mirrors/exop/ExoPlayer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考