跳至内容
返回

魔改 GLSurfaceView 实现跨 View 零拷贝纹理共享

发布于:  at  05:10 上午

问题:两个 GLSurfaceView 之间没法共享纹理

先说一个实际会遇到的场景:你用 OpenGL ES 在一个 GLSurfaceView 上渲染了内容(比如视频帧、3D 场景、自定义 GL 动画),现在想在另一个 View 上也显示同样的画面。

直觉的做法是把纹理 ID 传过去,让第二个 View 直接 glBindTexture 画出来。但这不行——两个 GLSurfaceView 各自有独立的 EGL Context,Context A 创建的纹理在 Context B 里是不存在的。

EGL 规范提供了解决办法:创建 Context B 时可以指定和 Context A 共享。共享之后,A 的纹理、Buffer Object、Shader Program 在 B 里都可见。

这个机制叫 Shared Context,eglCreateContext 的第三个参数就是用来传共享对象的。

问题在于 Android 的 GLSurfaceView 不给你这个口子。

GLSurfaceView 的 EGL 版本问题

Android 的 GLSurfaceView 内部用的是 javax.microedition.khronos.egl 包——这是 EGL 1.0 的 Java 绑定,从 API 1 就有了。它的 EGLContextFactory 接口长这样:

// android.opengl.GLSurfaceView.EGLContextFactory
public interface EGLContextFactory {
    javax.microedition.khronos.egl.EGLContext createContext(
        javax.microedition.khronos.egl.EGL10 egl,
        javax.microedition.khronos.egl.EGLDisplay display,
        javax.microedition.khronos.egl.EGLConfig eglConfig
    );
    void destroyContext(
        javax.microedition.khronos.egl.EGL10 egl,
        javax.microedition.khronos.egl.EGLDisplay display,
        javax.microedition.khronos.egl.EGLContext context
    );
}

参数和返回值全是 javax.microedition.khronos.egl 下的类型。

而 Android 从 API 17 开始提供了 android.opengl.EGL14——EGL 1.4 的绑定,也是现在主流使用的 EGL 接口。很多第三方 SDK(视频 SDK、相机库等)内部都用 EGL14,回调给你的 Context 类型是 android.opengl.EGLContext

这两个类 javax.microedition.khronos.egl.EGLContextandroid.opengl.EGLContext 之间没有继承关系,没法互转:

javax.microedition.khronos.egl.EGLContext  -- GLSurfaceView 用的
android.opengl.EGLContext                   -- EGL14 / 第三方 SDK 用的

所以如果你想让一个 GLSurfaceView 和某个 EGL14 Context 共享纹理,标准 API 做不到。EGLContextFactory 的参数类型就把路堵死了。

即便不涉及第三方 SDK,想让两个 GLSurfaceView 之间做 Context 共享,原版的接口也很别扭——你得在旧版 EGL10 的接口里折腾,而 EGL10 的 API 比 EGL14 难用不少。

方案:从 AOSP fork 出 GL14SurfaceView

思路很直接:把 GLSurfaceView 的源码拷出来,把内部所有 javax.microedition.khronos.egl 调用换成 android.opengl.EGL14

这就是 GL14SurfaceView。逻辑和原版完全一样——GLThread 渲染循环、EglHelper 管理 EGL 生命周期、SurfaceHolder.Callback 处理 Surface 创建销毁——只是 EGL 接口从 1.0 换到了 1.4。

接口改造

原版 DefaultContextFactory

// 原版 GLSurfaceView 内部 (javax.microedition.khronos.egl)
public EGLContext createContext(EGL10 egl, EGLDisplay display, EGLConfig eglConfig) {
    return egl.eglCreateContext(display, eglConfig, EGL10.EGL_NO_CONTEXT, attrib_list);
}

改成 EGL14:

// GL14SurfaceView 内部 (android.opengl.EGL14)
public android.opengl.EGLContext createContext(
    android.opengl.EGLDisplay display,
    android.opengl.EGLConfig config
) {
    int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, mEGLContextClientVersion, EGL14.EGL_NONE};
    return EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, attrib_list, 0);
}

对外暴露的 EGLContextFactory 接口也跟着变:

public interface EGLContextFactory {
    android.opengl.EGLContext createContext(EGLDisplay display, EGLConfig eglConfig);
    void destroyContext(EGLDisplay display, android.opengl.EGLContext context);
}

EglHelper 改造

EglHelper 里整条 EGL 生命周期全部切到 EGL14 静态方法。改动是机械性的,举几个例子:

// 获取 Display
mEglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);

// 初始化
EGL14.eglInitialize(mEglDisplay, version, 0, version, 1);

// 创建 Context(通过 EGLContextFactory,允许外部注入 sharedContext)
mEglContext = view.mEGLContextFactory.createContext(mEglDisplay, mEglConfig);

// 创建 Window Surface
mEglSurface = EGL14.eglCreateWindowSurface(display, config, nativeWindow, null, 0);

// 绑定
EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext);

// 交换缓冲
EGL14.eglSwapBuffers(mEglDisplay, mEglSurface);

另外多加一个方法让外部能拿到当前 GLThread 的 EGLContext:

public EGLContext getEglContext() {
    return mGLThread.getEglContext();
}

这在后面做 Context 共享时会用到。

用法:跨 View 纹理共享

有了 GL14SurfaceView,做 Context 共享就很自然了。

SharedEGLContextFactory

写一个 EGLContextFactory 实现,在 eglCreateContext 时把要共享的 Context 传进去:

class SharedEGLContextFactory(
    private val sharedContext: EGLContext
) : GL14SurfaceView.EGLContextFactory {

    private val EGL_CONTEXT_CLIENT_VERSION = 0x3098

    override fun createContext(display: EGLDisplay, eglConfig: EGLConfig): EGLContext {
        val attribList = intArrayOf(EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
        return EGL14.eglCreateContext(
            display,
            eglConfig,
            sharedContext,  // 替代 EGL_NO_CONTEXT
            attribList,
            0
        )
    }

    override fun destroyContext(display: EGLDisplay?, context: EGLContext?) {
        EGL14.eglDestroyContext(display, context)
    }
}

改动就一行——EGL14.EGL_NO_CONTEXT 换成 sharedContext。效果是新建的 Context 和传入的 Context 共享纹理命名空间。

示例:两个 View 显示同一个纹理

假设 viewA 是主渲染 View,它创建了一个纹理并往上画了东西。现在想让 viewB 也显示同样的内容。

// viewA: 主渲染 View,正常创建
val viewA = GL14SurfaceView(context).apply {
    setEGLContextClientVersion(2)
    setRenderer(mainRenderer)
}

// mainRenderer 在 onSurfaceCreated 里创建了一个纹理
// textureId 保存在某个共享变量里

viewA 的 GL 线程跑起来拿到 EGLContext 后,创建 viewB

// viewB: 共享 viewA 的 Context
val viewB = GL14SurfaceView(context).apply {
    setEGLContextFactory(SharedEGLContextFactory(viewA.eglContext))
    setEGLContextClientVersion(2)
    setRenderer(mirrorRenderer)  // 直接用 viewA 创建的 textureId 绘制
    renderMode = GL14SurfaceView.RENDERMODE_WHEN_DIRTY
}

mirrorRendereronDrawFrame 里可以直接绑 viewA 创建的纹理:

val mirrorRenderer = object : GL14SurfaceView.Renderer {
    override fun onSurfaceCreated(config: EGLConfig?) {
        // 不需要再创建纹理,viewA 的纹理在这个 Context 里已经可见
    }
    override fun onSurfaceChanged(width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
    }
    override fun onDrawFrame() {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
        // 直接用 viewA 的 textureId 画
        drawTexture(sharedTextureId)
    }
}

没有 glReadPixels,没有 Bitmap 中转,没有 CPU 参与的像素数据搬运。两个 View 画的是 GPU 显存里同一份纹理数据。

可以继续扩展

同一个 Context 可以被多个 View 共享。创建 viewCviewD 时都指向同一个 sharedContext,就可以做 N 路分发。每多一路只增加一次 draw call 的开销。

实际应用场景

这个方案能用在不少地方:

举个第三方 SDK 的使用方式。比如某个视频 SDK 在回调里给了你 textureIdeglContext

sdk.setVideoFrameCallback { textureId, eglContext ->
    if (surfaceView == null) {
        // 首次回调:创建共享 Context 的 GL14SurfaceView
        surfaceView = GL14SurfaceView(context).apply {
            setEGLContextFactory(SharedEGLContextFactory(eglContext))
            setEGLContextClientVersion(2)
            setRenderer(myRenderer)
            renderMode = GL14SurfaceView.RENDERMODE_WHEN_DIRTY
        }
        container.addView(surfaceView)
    }
    // 后续回调:更新纹理 ID,请求重绘
    currentTextureId = textureId
    surfaceView?.requestRender()
}

不需要 YUV 回调,不需要 CPU 转码,不需要开多个 SDK 实例。

注意事项

线程安全

两个 View 的 GLThread 是不同线程。纹理 ID 的写入和读取如果在不同线程,要注意同步。requestRender() 本身是线程安全的(内部有 synchronized),可以在任意线程调用。

如果遇到偶发的画面撕裂,可以在写纹理的一端加 glFenceSync / glWaitSync

生命周期

共享 Context 有依赖关系:如果 viewA 的 Context 被销毁了,而 viewB 还在用共享的纹理,会 EGL_BAD_CONTEXT 或直接 crash。

销毁顺序要注意——先停消费者(共享方),再停生产者(被共享方)。

preserveEGLContextOnPause

两个 View 都建议设成 true。不然 Activity 暂停时 Context 被销毁,恢复后共享关系就断了,得重新建。

OES 纹理

如果共享的纹理类型是 GL_TEXTURE_EXTERNAL_OES(外部纹理,常见于相机预览和视频解码),Shader 里要声明:

#extension GL_OES_EGL_image_external : require
uniform samplerExternalOES sTexture;

普通的 sampler2D 采样不了 OES 纹理,会黑屏。

和 YUV 回调方案的对比

如果不做 Context 共享,常见的替代方案是从 GPU 把像素数据读回 CPU(glReadPixels 或 YUV 回调),再上传到另一个 View。1080P 30fps 下的开销大概是:

YUV 回调共享 Context
数据路径GPU -> CPU -> GPUGPU -> GPU
CPU 额外占用~25%接近零
单帧延迟40~60ms<5ms
每帧内存搬运~6MB (1080P YUV)0
扩展开销每多一路翻倍每多一路多一次 draw call

差距来源很直接:YUV 方案有两次跨总线传输(GPU->CPU 回读 + CPU->GPU 上传),共享 Context 方案里数据始终在 GPU 显存里。

总结

GL14SurfaceView 的改动不大——从 AOSP fork 出来把 EGL 调用换一遍,实际改动不超过 100 行。SharedEGLContextFactory 更短,20 行左右。但有了这两个东西,Android 上跨 View 的 GPU 纹理共享就通了,不再需要像素数据过 CPU 绕一圈。

代码本身不复杂。复杂的是搞清楚为什么标准 GLSurfaceView 做不了这件事——两套 EGL 接口的类型隔离,不翻 AOSP 源码的话不太容易意识到。


在以下平台分享此文章: