欢迎访问 AI Skills Video ! 海量优质视频教程,助你提升技能。

DevLog: Android 视频直通 GPU 预览:原生解码器 + GPU 旋转修正全链路解析-性能飞起

老张 2026年8月13日 11 次阅读
本文深入剖析 Android 端视频预览管线的核心实现:视频帧从原生解码器出发, 经由 SurfaceTexture 零拷贝直通 GPU,最终在 OpenGL ES shader 中完成旋转修正并显示到屏幕。 全链路无 CPU 参与帧数据搬运,解码 → 纹理 → 滤镜 → 显示一气呵成。

0. 引言:为什么需要「直通 GPU」?

Android 上播放视频最常见的方式是 MediaPlayer + SurfaceView / TextureView。 这种方式简单直接,但有一个根本限制:你无法在渲染路径上插入自定义的 GPU 处理。 想要做实时滤镜、旋转修正、文字叠加、图片合成——就必须把帧数据拿到 GPU 纹理里。

传统的「取帧 → CPU 上传 → GPU 渲染」路径有两个致命问题:

  1. CPU 拷贝开销:4K 视频每帧约 24MB(YUV 4:2:0),30fps 下每秒搬运 720MB,CPU 吃不消。
  2. 延迟叠加: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 无滤镜路径的开销

无滤镜时,渲染路径仅包含:

  1. AMediaCodec_releaseOutputBuffer(render=true) — 硬件信号,几乎零开销
  2. ASurfaceTexture_updateTexImage() — 纹理绑定,无像素拷贝
  3. 一次 glDrawArrays() — 全屏四边形,2 个三角形
  4. 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 只做调度」**:

  1. MediaCodec SURFACE 模式:解码帧直写 Surface,零 CPU 拷贝
  2. SurfaceTexture + OES 纹理:解码帧自动成为 GL 纹理,shader 可直接采样
  3. transformMatrix 含旋转:Android 独有的便利——shader 采样后画面自动转正
  4. 幂等旋转模型:编码尺寸 + 旋转角 → 显示尺寸,始终从原始值重算,避免状态污染
  5. 滤镜链直通优化:无滤镜时零额外开销,有滤镜时 Pass0 完成 OES→2D + 旋转修正
  6. 时间轴主时钟:音频硬件时钟驱动,视频帧做等待/丢帧同步

这条管线在 4K 视频上也能保持流畅预览,CPU 占用极低,功耗主要由硬件解码器和 GPU 承担—— 这正是移动端「能效比」最优的方案。