DevLog: Android 视频直通 GPU 预览:原生解码器 + GPU 旋转修正全链路解析-性能飞起
0. 引言:为什么需要「直通 GPU」?
Android 上播放视频最常见的方式是 MediaPlayer + SurfaceView / TextureView。
这种方式简单直接,但有一个根本限制:你无法在渲染路径上插入自定义的 GPU 处理。
想要做实时滤镜、旋转修正、文字叠加、图片合成——就必须把帧数据拿到 GPU 纹理里。
传统的「取帧 → CPU 上传 → GPU 渲染」路径有两个致命问题:
- CPU 拷贝开销:4K 视频每帧约 24MB(YUV 4:2:0),30fps 下每秒搬运 720MB,CPU 吃不消。
- 延迟叠加:CPU 拷贝 + 纹理上传至少多一帧延迟,预览体验劣化。
我们的方案是 MediaCodec SURFACE 模式 + OES 纹理直通:解码器直接把帧写入 GPU 纹理, CPU 完全不参与帧数据搬运。整条链路如下:
┌─────────────────┐ Surface ┌──────────────────┐ OES Texture ┌──────────────┐
│ AMediaCodec │ ──────────────▶ │ SurfaceTexture │ ──────────────▶ │ GL Shader │
│ (硬件解码器) │ 零拷贝直通 │ (GL_TEXTURE_ │ updateTexImage │ (旋转 + 滤镜 │
│ │ │ EXTERNAL_OES) │ │ + 显示) │
└─────────────────┘ └──────────────────┘ └──────────────┘
1. 整体架构
1.1 模块分工
整条预览管线涉及四个层次的协作:
| 层次 | 组件 | 职责 |
|---|---|---|
| Java 层 | VideoPreviewView (TextureView) |
提供显示 Surface,管理 MediaPlayer 生命周期 |
| JNI 桥接 | nativebridge |
Kotlin ↔ C++ 跨语言调用,旋转角下发 |
| Native 解码 | AndroidMediaDecoder |
AMediaExtractor + AMediaCodec,SURFACE 模式输出 |
| Native 渲染 | GpuPreviewPipeline + GpuFilterChain |
GL 线程、OES 纹理、滤镜链、EGL 显示 |
1.2 数据流全景
Kotlin 层
│ pipeline.init(surface, decoder) // 传入 TextureView 的 Surface + 解码器
│ decoder.openFd(fd, offset, length) // 打开视频文件
│ pipeline.start() // 启动 GL 线程渲染循环
▼
GL 线程(native)
│
│ ┌─────── 每帧循环 ───────┐
│ │ │
│ │ 1. decodeOneFrame() │ ← AMediaCodec 解码一帧
│ │ ↓ │
│ │ 2. releaseOutput(render=true) ← 帧直写 Surface(零拷贝)
│ │ ↓ │
│ │ 3. updateTexImage() │ ← SurfaceTexture 更新 OES 纹理
│ │ ↓ │
│ │ 4. getTransformMatrix()│ ← 获取纹理坐标变换矩阵(含旋转)
│ │ ↓ │
│ │ 5. GpuFilterChain │ ← OES → 滤镜 → 屏幕
│ │ .render() │ (letterbox 等比居中)
│ │ ↓ │
│ │ 6. swapBuffers() │ ← EGL 提交到显示 Surface
│ │ │
│ └────────────────────────┘
▼
ANativeWindow → TextureView → 屏幕
2. 原生解码器:AMediaCodec SURFACE 模式
2.1 为什么用 NDK 而不是 Java MediaCodec?
项目的核心渲染逻辑(滤镜链、文字叠加、图片合成)全部在 C++ native 层实现。
使用 NDK AMediaCodec 可以直接在 GL 线程内驱动解码,避免 Java ↔ Native 的跨进程
Binder 开销,更重要的是——解码和渲染在同一个线程内串行执行,天然满足
SurfaceTexture 的线程亲和性约束(updateTexImage 必须在纹理绑定的 GL 上下文线程调用)。
2.2 解码器初始化流程
// 1. 创建 MediaExtractor( demuxer )
mExtractor = AMediaExtractor_new();
AMediaExtractor_setDataSourceFd(mExtractor, fd, offset, length);
// 2. 遍历轨道,选择第一个视频轨
for (size_t i = 0; i < numTracks; ++i) {
AMediaFormat *format = AMediaExtractor_getTrackFormat(mExtractor, i);
const char *mime = nullptr;
AMediaFormat_getString(format, AMEDIAFORMAT_KEY_MIME, &mime);
if (strncmp(mime, "video/", 6) == 0) {
AMediaExtractor_selectTrack(mExtractor, i);
// 提取编码宽高(旋转前的原始尺寸)
AMediaFormat_getInt32(format, AMEDIAFORMAT_KEY_WIDTH, &mCodedW);
AMediaFormat_getInt32(format, AMEDIAFORMAT_KEY_HEIGHT, &mCodedH);
break;
}
}
// 3. 创建解码器
mCodec = AMediaCodec_createDecoderByType(mime);
// 4. 关键:configure 时传入 Surface → 启用 SURFACE 直通模式
// surface != NULL 告诉解码器:解码帧直接渲染到这个 Surface 上,
// 不要输出到 CPU 可读的 ByteBuffer。
AMediaCodec_configure(mCodec, format, surface, nullptr, 0);
// 5. 启动解码
AMediaCodec_start(mCodec);
核心参数是 configure 的第三个参数 surface:
surface = NULL:BUFFER 模式,解码帧存入 CPU 内存(ByteBuffer),需要memcpy取出。surface != NULL:SURFACE 模式,解码帧直接渲染到 Surface(GPU 显存),零 CPU 拷贝。
2.3 SURFACE 模式的帧输出
SURFACE 模式下,decodeOneFrame 的核心逻辑极其简洁:
nativebase::FrameData AndroidMediaDecoder::decodeOneFrame() {
// ... 喂入编码数据、轮询输出 ...
if (mOutputMode == nativebase::OutputMode::SURFACE) {
// ★ 关键:render=true → 将解码帧渲染到 Surface(零拷贝)
AMediaCodec_releaseOutputBuffer(mCodec, outputIdx, true);
// ★ 更新 SurfaceTexture 的 GL 纹理图像
ASurfaceTexture_updateTexImage(mSurfaceTexture);
// ★ 获取纹理坐标变换矩阵(包含 Y-flip + 旋转)
ASurfaceTexture_getTransformMatrix(mSurfaceTexture, frame.transformMatrix);
frame.textureId = mGLTextureId; // OES 纹理 ID
frame.ptsUs = bufferInfo.presentationTimeUs;
frame.width = mWidth; // 旋转后的显示尺寸
frame.height = mHeight;
frame.isValid = true;
return frame;
}
// ... BUFFER 模式(CPU 拷贝,此处省略)...
}
三个关键 API 调用:
| API | 作用 |
|---|---|
AMediaCodec_releaseOutputBuffer(codec, idx, true) |
render=true 将解码帧渲染到绑定的 Surface |
ASurfaceTexture_updateTexImage(st) |
将 Surface 上的最新帧更新到 GL_TEXTURE_EXTERNAL_OES 纹理 |
ASurfaceTexture_getTransformMatrix(st, matrix) |
获取纹理坐标变换矩阵(Android 上此矩阵已包含视频旋转) |
零拷贝的本质:解码器输出的是 GPU 显存中的 BufferQueue 节点,
updateTexImage只是将该节点绑定为 GL 纹理,没有像素数据搬运。
3. OES 纹理:解码帧的 GL 入口
3.1 GL_TEXTURE_EXTERNAL_OES
Android 的 SurfaceTexture 产出的纹理类型是 GL_TEXTURE_EXTERNAL_OES,
而非普通的 GL_TEXTURE_2D。这是因为解码帧的内存布局是 YUV 格式,
OES 扩展允许 GPU 硬件直接采样 YUV 纹理并自动完成 YUV → RGB 转换。
创建方式:
glGenTextures(1, &oesTex);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTex);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
对应的 GLSL shader 必须使用 samplerExternalOES 和 GL_OES_EGL_image_external_essl3 扩展:
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
uniform samplerExternalOES uTexture;
uniform mat4 uTexMatrix;
in vec2 vTexCoord;
out vec4 fragColor;
void main() {
// uTexMatrix 将原始纹理坐标映射到正确方向(含 Y-flip + 旋转)
vec2 transformedCoord = (uTexMatrix * vec4(vTexCoord, 0.0, 1.0)).xy;
fragColor = texture(uTexture, transformedCoord);
}
3.2 SurfaceTexture 的创建(JNI 桥接)
NDK 没有纯原生创建 SurfaceTexture 的 API,必须通过 JNI 调用 Java 构造函数:
// GL 线程上,通过 JNI 创建 Java SurfaceTexture
jclass stClass = env->FindClass("android/graphics/SurfaceTexture");
jmethodID stCtor = env->GetMethodID(stClass, "<init>", "(I)V");
jobject st = env->NewObject(stClass, stCtor, (jint)oesTex);
mJavaSurfaceTexture = env->NewGlobalRef(st);
// 从 Java SurfaceTexture 获取 native ASurfaceTexture 句柄
ASurfaceTexture *ast = ASurfaceTexture_fromSurfaceTexture(env, st);
// 从 ASurfaceTexture 获取 ANativeWindow,挂载到解码器
ANativeWindow *window = ASurfaceTexture_acquireANativeWindow(ast);
decoder->setSurfaceTexture(ast, oesTex);
这条链路建立了 解码器 → SurfaceTexture → OES 纹理 的物理连接。
4. 旋转修正:Android 的「免费午餐」
4.1 问题:竖拍视频为什么需要旋转?
手机竖拍视频的实际存储方式非常「取巧」:
编码尺寸是横向的,例如 3840×2160(4K 横向)
MP4 容器元数据(display matrix)标记旋转 -90°
播放器读取旋转角,显示时按 2160×3840 竖向呈现
如果忽略旋转信息,直接用编码尺寸显示,竖屏内容会被拉伸进横向画布—— 人看到的就是一片被压扁的画面。
4.2 Android 的独特优势:transformMatrix 已含旋转
Android 和 HarmonyOS 在 OES 纹理变换矩阵的行为上存在关键差异:
| 平台 | 变换矩阵来源 | 是否包含旋转 | 是否包含 Y-flip |
|---|---|---|---|
| Android | SurfaceTexture.getTransformMatrix() |
是 ✅ | 是 ✅ |
| HarmonyOS | OH_NativeImage_GetTransformMatrixV2() |
否 ❌ | 是 ✅ |
Android 的 getTransformMatrix() 返回的 4×4 矩阵已经包含了完整的变换:
Y 轴翻转(视频 top-to-bottom vs OpenGL bottom-to-top)
视频旋转(来自 MP4 display matrix 的旋转角)
这意味着在 Android 上,shader 采样 OES 纹理时,画面内容已经是正确方向。 解码器只需要修正「上报的宽高」,使画布 / letterbox 按旋转后的宽高比计算即可。
4.3 幂等旋转模型
解码器内部维护一个「编码尺寸 + 旋转角 → 显示尺寸」的幂等模型:
// 成员变量
int32_t mCodedW = 0; // 编码宽度(旋转前,来自轨道 format)
int32_t mCodedH = 0; // 编码高度(旋转前)
int32_t mRotationDegrees = 0; // 旋转角(归一化 [0, 360))
// 幂等计算:始终从编码尺寸重算,不会重复交换宽高
void AndroidMediaDecoder::applyRotation() {
if (mCodedW <= 0 || mCodedH <= 0) {
mWidth = mCodedW;
mHeight = mCodedH;
return;
}
// 90°/270° 需交换宽高;0°/180° 保持
bool swap = (mRotationDegrees % 180) != 0;
mWidth = swap ? mCodedH : mCodedW;
mHeight = swap ? mCodedW : mCodedH;
}
幂等性:无论调用多少次 applyRotation(),只要 mCodedW/mCodedH/mRotationDegrees
不变,结果就不变。这避免了在 OUTPUT_FORMAT_CHANGED 等事件中重复交换宽高导致的错误。
4.4 旋转角的权威来源
旋转信息的获取存在跨设备不一致问题:
权威源:Java MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
│ 跨设备稳定可靠
│ 通过 JNI 下发到 native 层
▼
decoder.setRotationDegrees(degrees)
│ 归一化到 [0, 360)
│ 设置 mRotationSetByJava = true(禁用 NDK 回退)
│ 调用 applyRotation()
▼
NDK 回退:AMediaFormat_getBuffer(format, "rotation-matrix", ...)
│ 部分设备(如荣耀 Android 14)不返回该 buffer
│ 仅在 Java 层未下发时启用
▼
解析 3×3 rotation-matrix 对角线
│ |m[0]| < 0.5 && |m[4]| < 0.5 → 90°/270°(需交换宽高)
▼
applyRotation()
为什么 Java 层是权威源? 因为部分 OEM 定制的 Android 系统中,
NDK 层的 AMediaFormat_getBuffer("rotation-matrix") 不返回数据,
而 Java 层的 MediaMetadataRetriever 跨所有设备稳定工作。
4.5 旋转修正的完整链路
┌──────────────────────────────────────────────────────────────────┐
│ Kotlin 层 │
│ val rotation = MediaMetadataRetriever │
│ .setDataSource(context, uri) │
│ .extractMetadata(METADATA_KEY_VIDEO_ROTATION) │
│ decoder.setRotationDegrees(rotation.toInt()) │
└──────────────────────────┬───────────────────────────────────────┘
│ JNI
▼
┌──────────────────────────────────────────────────────────────────┐
│ AndroidMediaDecoder::setRotationDegrees(90) │
│ mRotationDegrees = 90 │
│ mRotationSetByJava = true // 禁用 NDK 回退 │
│ applyRotation(): │
│ swap = (90 % 180) != 0 → true │
│ mWidth = mCodedH = 2160 // 显示宽 = 编码高 │
│ mHeight = mCodedW = 3840 // 显示高 = 编码宽 │
└──────────────────────────┬───────────────────────────────────────┘
│ 返回 FrameData
▼
┌──────────────────────────────────────────────────────────────────┐
│ GpuFilterChain::render(oesTex, texMat, 2160, 3840, viewW, viewH)│
│ // texMat 已包含旋转(Android 特性),shader 采样后画面已转正 │
│ // videoW=2160, videoH=3840 → letterbox 按竖屏宽高比居中 │
│ applyLetterboxViewport(2160, 3840, viewW, viewH) │
│ videoAspect = 2160/3840 = 0.5625 (竖屏) │
│ → 高度撑满,左右留黑边 │
└──────────────────────────────────────────────────────────────────┘
5. GPU 滤镜链:OES → 直通 / FBO ping-pong → 屏幕
5.1 无滤镜:零开销直通
当没有激活的滤镜时,渲染路径极简——一次 GL 调用直出屏幕:
void GpuFilterChain::render(oesTex, texMat, videoW, videoH, viewW, viewH) {
if (mFilters.empty()) {
// 清屏 + letterbox 视口
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
applyLetterboxViewport(videoW, videoH, viewW, viewH);
// OES 直通:shader 采样 + 纹理矩阵变换 → 直接输出到屏幕
mOesProgram.use();
mOesProgram.setTexture("uTexture", 0, oesTex, GL_TEXTURE_EXTERNAL_OES);
mOesProgram.setMat4("uTexMatrix", texMat);
mQuad.draw();
return; // 完成!一次 draw call
}
// ... 有滤镜的路径 ...
}
零额外开销:没有 FBO 创建、没有纹理拷贝、没有 ping-pong 切换。 OES 纹理 → shader 采样(应用 texMat 旋转)→ 直接输出到 default framebuffer。
5.2 有滤镜:Pass0 OES→2D + ping-pong 滤镜链
当需要应用滤镜时,必须先将 OES 纹理转换为普通 2D 纹理(因为滤镜 shader 使用 sampler2D),
然后在 ping-pong FBO 之间来回切换执行滤镜:
Pass0: OES 纹理 ──shader──▶ FBO[0](2D 纹理,已完成旋转修正)
Pass1: FBO[0] ──滤镜1──▶ FBO[1]
Pass2: FBO[1] ──滤镜2──▶ FBO[0]
...
LastPass: FBO[N] ──末滤镜──▶ 屏幕(default framebuffer + letterbox)
// Pass0: OES → FBO[0]
glBindFramebuffer(GL_FRAMEBUFFER, mFbo[0]);
mOesProgram.use();
mOesProgram.setTexture("uTexture", 0, oesTex, GL_TEXTURE_EXTERNAL_OES);
mOesProgram.setMat4("uTexMatrix", texMat); // 旋转修正在此步完成
mQuad.draw();
// 滤镜 ping-pong
int read = 0;
for (size_t fi = 0; fi < totalFilters; ++fi) {
for (int sp = 0; sp < filter->passCount(); ++sp) {
bool isLast = (fi == totalFilters - 1) && (sp == subPasses - 1);
if (isLast) {
// 最后一个 pass → 输出到屏幕
glBindFramebuffer(GL_FRAMEBUFFER, 0);
applyLetterboxViewport(videoW, videoH, viewW, viewH);
} else {
// 中间 pass → 写入另一个 FBO
glBindFramebuffer(GL_FRAMEBUFFER, mFbo[1 - read]);
}
filter->render(mQuad, mFboTex[read], mFboW, mFboH, sp);
if (!isLast) read = 1 - read;
}
}
设计要点:旋转修正发生在 Pass0(OES → 2D 转换), 后续所有滤镜操作的都是已经转正的 2D 纹理,无需关心旋转。
5.3 Letterbox 等比居中
无论有无滤镜,最终输出都经过 letterbox 视口计算,确保视频按原始宽高比居中显示:
void GpuFilterChain::applyLetterboxViewport(int videoW, int videoH,
int viewW, int viewH) {
float videoAspect = (float)videoW / (float)videoH;
float viewAspect = (float)viewW / (float)viewH;
if (videoAspect > viewAspect) {
// 视频更宽 → 宽度撑满,上下留黑边
int h = (int)(viewW / videoAspect);
glViewport(0, (viewH - h) / 2, viewW, h);
} else {
// 视频更高(如竖屏)→ 高度撑满,左右留黑边
int w = (int)(viewH * videoAspect);
glViewport((viewW - w) / 2, 0, w, viewH);
}
}
这里的 videoW/videoH 是旋转后的显示尺寸(如 2160×3840),
确保竖屏视频在 letterbox 时按竖向宽高比居中,而非被拉伸进横向画布。
6. GL 线程模型与渲染循环
6.1 为什么需要独立 GL 线程?
SurfaceTexture 有一个严格的约束:updateTexImage() 必须在纹理绑定的 GL 上下文所在线程调用。
由于我们的 GL 上下文在独立的渲染线程上创建,因此解码 + updateTexImage + GL 绘制
必须全部在同一个 GL 线程内完成。
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Kotlin 主线程 │ │ GL 线程 │ │ 音频线程 │
│ │ │ │ │ (AAudio) │
│ UI 事件 │ start() │ setupGl() │ │ │
│ seek 请求 │ ──────▶ │ renderLoop() │ ◀────── │ 时钟源 │
│ 滤镜更新 │ │ decodeOne() │ │ │
│ │ │ render() │ │ │
│ │ │ swapBuffers()│ │ │
└──────────────┘ └──────────────┘ └──────────────┘
6.2 渲染循环核心
void GpuPreviewPipeline::renderLoop() {
while (mRunning) {
applyPendingUpdates(); // 消费滤镜更新
if (!mPlaying) {
// 暂停态:处理单帧渲染请求
if (mRenderOneFrameRequested) {
// 解码一帧 + 合成渲染(视频 + 图片叠加 + 文字叠加)
FrameData frame = mDecoder->decodeOneFrame();
renderVideoOverlay(...);
renderImageOverlay(...);
renderTextOverlay(...);
mContext.swapBuffers();
} else {
usleep(5000); // 让出 CPU
}
continue;
}
// 播放态:时间轴驱动
// [1] 确定时间轴位置(音频时钟 > 墙钟回退)
int64_t tlPos = audioDriving ? mAudioPlayer->getPositionUs()
: wallClockPosition();
// [2] 解码一帧(SURFACE 模式:内部已 updateTexImage)
FrameData frame = mDecoder->decodeOneFrame();
// [3] 音画同步:帧超前 → 等待;帧落后 → 丢帧追赶
int64_t diffUs = frameTlPtsUs - tlPos;
if (diffUs > 40000) usleep(waitUs); // 超前 >40ms 等待
if (diffUs < -80000 && audioDriving) continue; // 落后 >80ms 丢帧
// [4] 合成渲染
renderVideoOverlay(...); // 视频背景(含动画变换)
renderImageOverlay(...); // 图片 fit 叠加
renderTextOverlay(...); // 文字叠加
mContext.swapBuffers(); // EGL 提交显示
}
}
6.3 音画同步策略
时间轴是唯一的「主时钟」,音频和视频都是从属:
| 时钟源 | 优先级 | 说明 |
|---|---|---|
| AAudio 硬件时钟 | 最高 | 音频播放位置,精确到微秒 |
| 墙钟回退 | 次 | start() 时记录锚点,按 steady_clock 推进 |
视频帧的同步策略:
超前音频 > 40ms:
usleep()等待,上限 100ms 防卡死落后音频 > 80ms:丢帧(跳过渲染),快速解码下一帧追赶
偏差 ≤ 40ms:正常渲染
7. 初始化时序:关键的先后顺序
GPU 预览管线的初始化有严格的时序要求,顺序错误会导致黑屏或崩溃:
1. pipeline.init(surface, decoder)
│ 创建 GL 线程
│ GL 线程内:
│ ├─ EGL 环境初始化(绑定到 TextureView 的 ANativeWindow)
│ ├─ 创建 OES 纹理(GL_TEXTURE_EXTERNAL_OES)
│ ├─ JNI 创建 Java SurfaceTexture(oesTex)
│ ├─ 设置解码器为 SURFACE 模式:decoder.setOutputMode(SURFACE)
│ └─ 挂载 SurfaceTexture:decoder.setSurfaceTexture(ast, oesTex)
│ └─ 内部:ASurfaceTexture_acquireANativeWindow → 配置 codec
│ 阻塞等待 GL 线程初始化完成
│
│ ★ 必须在 decoder.openFd() 之前!
│ 因为 openFd 内部 configureCodec 需要 mOutputWindow 已就绪
│
2. decoder.openFd(fd, offset, length)
│ AMediaExtractor 打开文件
│ selectVideoTrack() → 提取编码尺寸 + 旋转角
│ configureCodec() → surface=mOutputWindow(SURFACE 模式)
│ AMediaCodec_start()
│
3. pipeline.start()
│ 记录时钟锚点
│ 置 mPlaying = true
│ GL 线程开始 renderLoop()
踩坑记录:如果在
pipeline.init()之前调用decoder.openFd(), codec 会以surface=NULL(BUFFER 模式)配置,后续切换到 SURFACE 模式需要 重启 codec,增加不必要的延迟和复杂度。
8. 性能分析
8.1 零拷贝链路的性能优势
| 指标 | BUFFER 模式(CPU 拷贝) | SURFACE 模式(GPU 直通) |
|---|---|---|
| 4K 帧数据量 | ~24MB/帧 (YUV) | 0(GPU 显存内完成) |
| 30fps CPU 带宽 | ~720MB/s | 0 |
| 帧延迟 | +1 帧(拷贝时间) | 0 |
| GPU 滤镜能力 | 需先上传纹理 | 直接可用 |
| 功耗 | CPU 高负载 | 硬件解码 + GPU 低功耗 |
8.2 无滤镜路径的开销
无滤镜时,渲染路径仅包含:
AMediaCodec_releaseOutputBuffer(render=true)— 硬件信号,几乎零开销ASurfaceTexture_updateTexImage()— 纹理绑定,无像素拷贝- 一次
glDrawArrays()— 全屏四边形,2 个三角形 eglSwapBuffers()— 页面翻转
总计:1 次 draw call,0 次 FBO 切换,0 次纹理拷贝。
8.3 Letterbox 视口计算
letterbox 仅在 CPU 侧做几次浮点运算(applyLetterboxViewport),
GPU 侧通过 glViewport 自动完成等比缩放,无额外渲染开销。
9. 与 HarmonyOS 的对比
Android 和 HarmonyOS 在 GPU 预览管线上使用相同的架构(OES 纹理 + 滤镜链), 但在旋转处理上有本质差异:
| 特性 | Android | HarmonyOS |
|---|---|---|
| 变换矩阵 API | getTransformMatrix() |
GetTransformMatrixV2() |
| 矩阵包含旋转 | 是 | 否 |
| 矩阵包含 Y-flip | 是 | 是 |
| 旋转修正位置 | 解码器(修正宽高) | GPU 管线(手动组合旋转矩阵) |
| 旋转角来源 | Java MediaMetadataRetriever |
OH_MD_KEY_ROTATION (TrackFormat) |
| 旋转视频 shader | texMat 自动处理 | 需跳过 NativeImage 矩阵,使用独立旋转矩阵 |
Android 的「免费午餐」:getTransformMatrix() 返回的矩阵已经包含旋转 + Y-flip 的
完整变换,shader 采样后画面自动转正。解码器只需修正上报的宽高,让 letterbox 按正确的
宽高比计算即可。
HarmonyOS 的「手动挡」:GetTransformMatrixV2() 仅处理 Y-flip,不含旋转。
必须在 GPU 管线中手动构建包含 Y-flip + 旋转的独立矩阵,并在旋转视频时跳过
NativeImage 矩阵以避免冲突。
10. 总结
Android 视频直通 GPU 预览管线的核心设计哲学是**「让数据在 GPU 显存中流动,CPU 只做调度」**:
- MediaCodec SURFACE 模式:解码帧直写 Surface,零 CPU 拷贝
- SurfaceTexture + OES 纹理:解码帧自动成为 GL 纹理,shader 可直接采样
- transformMatrix 含旋转:Android 独有的便利——shader 采样后画面自动转正
- 幂等旋转模型:编码尺寸 + 旋转角 → 显示尺寸,始终从原始值重算,避免状态污染
- 滤镜链直通优化:无滤镜时零额外开销,有滤镜时 Pass0 完成 OES→2D + 旋转修正
- 时间轴主时钟:音频硬件时钟驱动,视频帧做等待/丢帧同步
这条管线在 4K 视频上也能保持流畅预览,CPU 占用极低,功耗主要由硬件解码器和 GPU 承担—— 这正是移动端「能效比」最优的方案。