5 步玩转 ExoPlayer 视频滤镜:轻松写出第一个 OpenGL 实时特效
5 步玩转 ExoPlayer 视频滤镜:轻松写出第一个 OpenGL 实时特效
【免费下载链接】ExoPlayerAn extensible media player for Android项目地址: https://gitcode.com/gh_mirrors/exop/ExoPlayer
做 Android 播放器的朋友大概都遇到过这样的尴尬:视频能播了,可画面永远是"素颜"状态。用户一张口就是"能不能加个滤镜、调个亮度、弄点氛围感",而你翻遍 MediaPlayer 的 API 也找不到任何与画面处理相关的入口。好消息是,Google 官方的播放引擎ExoPlayer 视频滤镜能力已经足够成熟,这篇文章就带你用最少的代码,把实时滤镜"塞"进渲染流程,让播放器从此学会七十二变。
你将看到:滤镜为什么能无缝融入播放过程、依赖怎么配、第一个灰度滤镜怎么跑起来、如何扩展出属于自己的特效,以及新手最容易踩的坑。
先把原理聊明白:滤镜到底"插"在画面的哪一环
想用好 ExoPlayer 视频滤镜,第一件事不是写代码,而是搞清楚它在整个播放流程中的位置。
一次播放大致走这样的流水线:解封装 → 解码 → 渲染 → 上屏。画面在解码器里出来时,还是一帧一帧的原始像素数据;在真正显示到屏幕之前,它们会先在 OpenGL 的世界里"过一遍"。
滤镜就插在这最后一段。ExoPlayer 把"处理一帧画面"这件事抽象成了两层:
- GlEffect(滤镜效果接口):只管"描述"这个效果是什么,它定义了一个方法
toGlShaderProgram(),负责把你的效果描述转换成真正能跑的着色器程序。 - GlShaderProgram(着色器程序):真正干活的角色。它负责加载 OpenGL 纹理、执行绘制、输出处理后的帧,最终再交还给渲染器显示。
这里的关键词是着色器——通俗讲,就是一个决定画面每个像素最终颜色的微型小程序,用 GLSL 语言编写。你的滤镜算法(灰度、暖色、浮雕……)本质都是一段像素变换逻辑,写进着色器里,GPU 就能对每一帧、每一个像素并行执行,这就是"实时"的底气所在。
用个生活化的类比:整条播放流水线是一条传送带,每帧画面是传送带上的产品,滤镜就是传送带上临时加装的一个质检关卡——产品经过它时被"重新上色"再放回传送带,流程本身毫发无损。也正因为如此,你完全不用碰解码器,不用改播放核心逻辑,就能自由地对画面做手脚。
先把台子搭好:依赖配置与官方示例
在写滤镜之前,先让工程里能拿到效果模块。ExoPlayer 的相关能力被拆分成了多个库,核心播放能力、UI 组件和效果处理各管一摊,在build.gradle里加上下面三个依赖即可:
dependencies { implementation 'com.google.android.exoplayer:exoplayer-core:2.X.X' implementation 'com.google.android.exoplayer:exoplayer-ui:2.X.X' implementation 'com.google.android.exoplayer:exoplayer-effect:2.X.X' }把2.X.X换成你查到的具体版本号(官方版本记录在 RELEASENOTES.md 里)。exoplayer-effect这个库就是我们这篇文章的主角——所有滤镜相关的类,包括GlEffect、内置的RgbFilter、HslAdjustment等,都在它的 library/effect 目录下。
想看真实的滤镜工程长什么样,直接 clone 这个仓库跑官方 demo:
git clone https://gitcode.com/gh_mirrors/exop/ExoPlayer仓库里的 demos/gl 是一个专门演示 GL 渲染的示例工程,里面有可运行的视频处理界面,还有现成的顶点着色器与片段着色器模板(.glsl文件),是很好的起点和对照样本。官方文档 docs/transforming-media.md 也把效果相关的完整能力列得很清楚,遇到细节问题时可以翻一翻。
跑通第一个滤镜:让画面瞬间变灰
环境就绪,先做一个效果最直观、代码最省事的滤镜:灰度。ExoPlayer 甚至帮你把灰度这种"标准操作"做成了开箱即用的工具类,连着色器都不用自己写:
// 一行代码创建灰度滤镜对象,内部自带 4x4 颜色变换矩阵 RgbFilter grayFilter = RgbFilter.createGrayscaleFilter();有了滤镜对象,把它交给播放器即可。ExoPlayer暴露了setVideoEffects()这个方法,接受一个效果列表,意味着以后想叠加多个效果,就往这个列表里多塞几个对象:
// 建好播放器,把滤镜链交给它,剩下的交给渲染管线 ExoPlayer player = new ExoPlayer.Builder(context).build(); player.setVideoEffects(ImmutableList.of(grayFilter)); player.setMediaItem(MediaItem.fromUri(videoUri)); player.prepare(); player.play();就这么多。播放器在渲染每一帧时,会自动把画面送进滤镜管线,灰度效果随之生效——而且是逐帧实时的,拖动进度条、切换清晰度都不会中断。
下面是效果模块自带测试资源里的一帧对比画面,左边是原图、右边是灰度处理后的输出,大家可以直观感受一下"管道插入"后的结果:
这里顺带提一句这个库的背景:这个仓库里的com.google.android.exoplayer2包已被官方标记为过期,新项目的正确姿势是迁移到androidx.media3(代码几乎一致,API 同名)。老项目照着本文的思路跑通概念,再平滑迁移过去,完全可行。
从"能用"到"好玩":扩展滤镜效果的三条路
灰度只是开胃菜。想做出复古、鲜艳、冷调、暗角等五花八门的特效,有三条路可以走,难度递增、灵活度也递增。
第一条路:用现成的效果类拼滤镜链。效果模块里内置了不少常用效果,而且列表的顺序就是执行的顺序——先调色再裁剪、先加滤镜再做水印,都由你说了算:
// 灰度之后,再把红色通道拉高一点,做"老照片泛红"的感觉 player.setVideoEffects( ImmutableList.of( RgbFilter.createGrayscaleFilter(), new RgbAdjustment.Builder().setRedScale(1.2f).build()));类似的积木还有HslAdjustment(色相/饱和度/亮度整体调节)、Contrast(对比度)、Brightness(亮度)、Crop(裁剪)、Presentation(画面缩放显示模式)等等,全都实现了GlEffect接口,直接往setVideoEffects里塞就行。比如下面这两个,分别是单纯的亮度提升和 HSL 综合调色的输出效果:
第二条路:自己写 GLSL 着色器。内置效果不够用的时候,就该上手写自己的效果了。实现思路是:自定义一个类实现GlEffect,在toGlShaderProgram()里返回一个SingleFrameGlShaderProgram的子类,把"一进一出、逐帧处理"的活交给自己的着色器程序。
先写一段片段着色器,做一个复古暖调效果——思路是压低蓝色、稍微抬升红黄,让画面泛出胶片般的暖色:
precision mediump float; uniform sampler2D uTexSampler; varying vec2 vTexCoord; void main() { vec4 color = texture2D(uTexSampler, vTexCoord); vec3 warm = vec3(color.r * 1.1, color.g * 1.02, color.b * 0.88); gl_FragColor = vec4(warm, color.a); }再把这段着色器和效果类接起来(下面是骨架示意,核心是编译着色器、绑定输入纹理、绘制全屏矩形这三个动作):
public class WarmToneEffect implements GlEffect { @Override public GlShaderProgram toGlShaderProgram(Context context, boolean useHdr) { return new SingleFrameGlShaderProgram(useHdr) { @Override public Size configure(int inputWidth, int inputHeight) { return new Size(inputWidth, inputHeight); } @Override public void drawFrame(int inputTexId, long presentationTimeUs) { // 1. 用 GlProgram 编译上面那几行 GLSL // 2. 把 inputTexId 对应的纹理绑定给 uTexSampler // 3. 绘制一个铺满屏幕的矩形,让片元着色器逐像素调色 } }; } }写顶点着色器和片段着色器时,可以直接参考 demos/gl/src/main/assets/ 里官方现成的.glsl模板,照着改比从零开始靠谱得多。
第三条路:交给官方转码引擎做离线处理。如果你想要的效果不只是"播放时看",还要导出来、剪辑后保存,那就去研究Transformer。它复用同一套效果体系,但工作模式从"实时渲染"变成了"逐帧处理并写文件",文章开头提到的 docs/transforming-media.md 就是它的说明书。
提前避坑,顺手把性能调上去
滤镜效果跑起来容易,跑得稳、跑得快才是真正的考验。下面是新手最容易翻车、也最影响体验的几个点:
坑一:隧道模式会悄悄绕过滤镜。Android 上有一种叫 tunneling 的渲染优化,解码后的画面可以不经过 OpenGL 直接上屏,省电省开销——但代价是滤镜根本不会生效。开了滤镜却看不到效果,先检查轨道选择器里是不是开了隧道模式,记得在滤镜场景下关掉它。
坑二:线程别乱用。播放器实例和它的状态修改(包括设置效果)要在主线程(application thread)上进行,而着色器程序的绘制发生在持有 OpenGL 上下文的那条线程上。跨线程乱调用,轻则IllegalStateException,重则画面花掉。官方在 docs/debug-logging.md 里对这类问题也有排查说明。
坑三:HDR 的一致性。toGlShaderProgram()里那个useHdr参数不是摆设——它告诉着色器输入画面的色彩空间(HDR 用 BT.2020 线性空间,SDR 用 BT.709)。同一个效果实例的前后调用必须保持一致,一会儿 HDR 一会儿 SDR,会直接报错。
性能上,记住三个"尽可能"。
- 尽可能合并着色器:多个简单效果最好拼进同一个着色器里一次算完,而不是一个效果开一次绘制。省下的不是一次两次 draw call,而是整个帧管线的延迟。
- 尽可能复用纹理:
GlShaderProgram内部有一个输出纹理池(BaseGlShaderProgram构造时可指定容量),正确释放输出帧、让纹理回池复用,能避免内存无谓增长。 - 尽可能贴合设备能力:低端机上全分辨率逐像素处理是灾难,可以考虑按设备性能动态降低渲染分辨率(比如输出 720p 就够),把流畅度让给体验。
真到了定位性能瓶颈的时候,借助 ExoPlayer 的日志系统把播放状态和帧处理情况打出来看,配合 Android Profiler 观察 GPU 负载,基本就能定位到是着色器算法太重,还是纹理分配太频繁。
下一步,把滤镜变成真正的产品功能
到这里,你已经掌握了 ExoPlayer 视频滤镜的完整链路:原理上知道滤镜插在渲染流水线的哪一环,工程上配好了依赖,代码上跑通了第一个灰度滤镜,还知道了内置效果库与自定义着色器两条扩展路径。下一步,就是把它变成用户能感知的功能了。
给你三个自然的进阶方向:
- 做滤镜切换 UI:把
RgbFilter、HslAdjustment、自定义效果对象放进一个列表,用户点一个换一个,实时生效。你的 App 离"滤镜切换如丝般顺滑"就差这一个列表。 - 做人脸特效:仓库里的 demos/transformer/withMediaPipe 提供了 MediaPipe 集成示例,可以在它基础上做美颜、人脸贴纸等玩法。
- 迁移到 media3:把代码迁移到
androidx.media3后,这套滤镜 API 依然成立,而且能拿到官方持续维护的新特性。
动手是最好的学习方式。建议你先 clone 仓库、跑通demos/gl示例,再亲手把灰度滤镜接到自己的播放器上——这个"原来这么简单"的时刻,比读十篇文章都值。如果这篇文章帮到了你,不妨收藏起来,下次做播放器功能时随时翻出来对照;有任何踩坑心得,也欢迎在评论区分享给大家。
【免费下载链接】ExoPlayerAn extensible media player for Android项目地址: https://gitcode.com/gh_mirrors/exop/ExoPlayer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
