OpenGL 入门(二)—— 渲染摄像头采集的预览画面

本篇主要内容:

  1. 将摄像头采集到的图像通过 OpenGL 绘制到屏幕上
  2. FBO 离屏渲染

在开始上述流程前,我们有必要对 SurfaceTexture 做一个简单了解,因为 OpenGL 需要通过它获取要绘制的图像。

1、认识 SurfaceTexture

SurfaceTexture 是 Android Graphics 包下提供的用于将相机采集到的视频或解码器解码出来的视频帧作为 OpenGL ES 的纹理的工具类:

/**
* 将图像流作为 OpenGL ES 的纹理进行帧捕获。
*
* 图像流可以来自相机预览或视频解码。从 SurfaceTexture 创建的 Surface 可用作
* android.hardware.camera2、android.media.MediaCodec、android.media.MediaPlayer
* 和 android.renderscript.Allocation API 的输出目标。调用 updateTexImage 时,会更新
* SurfaceTexture 创建时指定的纹理对象的内容,以包含来自图像流的最新图像。这可能会导致
* 跳过图像流的某些帧。
*
* 在指定旧的 android.hardware.Camera API 的输出目标时,也可以使用 SurfaceTexture 替代
* SurfaceHolder。这样做会导致将图像流的所有帧发送到 SurfaceTexture 对象,而不是设备的显示屏。
*
* 在对纹理进行采样时,应首先使用通过 getTransformMatrix(float[]) 查询的矩阵转换纹理坐标。每次
* 调用 updateTexImage 时,变换矩阵可能会发生变化,因此在更新纹理图像时应重新查询。这个矩阵将传
* 统的 2D OpenGL ES 纹理坐标列向量(形式为(s, t, 0, 1),其中 s 和 t 在包含区间[0, 1]内)转换为
* 流式纹理中的正确采样位置。该变换校正了图像流源的任何属性,使其与传统的 OpenGL ES 纹理不同。例如,
* 通过使用查询到的矩阵将列向量(0, 0, 0, 1)进行变换,可以从图像的左下角进行采样,而通过变换
* (1, 1, 0, 1)可以从图像的右上角进行采样。
*
* 纹理对象使用 GL_TEXTURE_EXTERNAL_OES 纹理目标,该目标由 GL_OES_EGL_image_external OpenGL ES
* 扩展定义。这限制了纹理的使用方式。每次绑定纹理时,必须将其绑定到 GL_TEXTURE_EXTERNAL_OES 目标而
* 不是 GL_TEXTURE_2D 目标。此外,任何从纹理进行采样的 OpenGL ES 2.0 着色器都必须使用类似于
* "#extension GL_OES_EGL_image_external : require" 的指令声明对该扩展的使用。这样的着色器还必须
* 使用 samplerExternalOES GLSL 采样器类型访问纹理。
*
* SurfaceTexture 对象可以在任何线程上创建。updateTexImage 只能在包含纹理对象的 OpenGL ES 上下文的
* 线程上调用。frame-available 回调在任意线程上调用,因此,不应直接从回调中调用 updateTexImage
*/
public class SurfaceTexture {
}

类上的注释很清楚地说明了 SurfaceTexture 的数据来源、适配哪些 API、如何进行纹理采样以及使用哪些纹理目标。接下来要看一下常用的 SurfaceTexture 的 API 的工作原理。

首先是构造方法:

    /**
     * 构造一个新的 SurfaceTexture,将图像流传输到指定的 OpenGL 纹理
     * @param texName OpenGL 纹理对象名称(例如通过 glGenTextures 生成)
     */
    public SurfaceTexture(int texName) {
        this(texName, false);
    }

    /**
     * 构造一个新的 SurfaceTexture,将图像流传输到指定的 OpenGL 纹理。
     * 
     * 在单缓冲模式下,应用程序负责对图像内容缓冲区进行序列化访问。每次要更新图像内容时,在图像
     * 内容生成器获取缓冲区所有权之前,必须调用 releaseTexImage()。例如,当使用 NDK 的
     * ANativeWindow_lock 和 ANativeWindow_unlockAndPost 函数生成图像内容时,在每次
     * ANativeWindow_lock 之前必须调用 releaseTexImage(),否则会失败。当使用 OpenGL ES
     * 生成图像内容时,在每帧的第一个 OpenGL ES 函数调用之前必须调用 releaseTexImage()
     *
     * @param texName OpenGL 纹理对象名称(例如通过 glGenTextures 生成)
     * @param singleBufferMode SurfaceTexture 是否处于单缓冲模式
     */
    public SurfaceTexture(int texName, boolean singleBufferMode) {
        mCreatorLooper = Looper.myLooper();
        mIsSingleBuffered = singleBufferMode;
        nativeInit(false, texName, singleBufferMode, new WeakReference<SurfaceTexture>(this));
    }

    /**
     * 与 SurfaceTexture(int, boolean) 不同,该构造函数以分离模式创建 SurfaceTexture。
     * 在调用 releaseTexImage() 和使用 OpenGL ES 生成图像内容之前,必须使用 
     * attachToGLContext 传入纹理名称
     */
    public SurfaceTexture(boolean singleBufferMode) {
        mCreatorLooper = Looper.myLooper();
        mIsSingleBuffered = singleBufferMode;
        nativeInit(true, 0, singleBufferMode, new WeakReference<SurfaceTexture>(this));
    }

通过构造函数创建 SurfaceTexture 对象之后,通常会调用 setOnFrameAvailableListener() 以监听 SurfaceTexture 是否有帧可用,如果有就让 OpenGL 进行绘制。构造方法中的 mCreatorLooper 就是用于在不同线程的 Handler 中进行回调的:

	public void setOnFrameAvailableListener(@Nullable OnFrameAvailableListener listener) {
        setOnFrameAvailableListener(listener, null);
    }

	public void setOnFrameAvailableListener(@Nullable final OnFrameAvailableListener listener,
            @Nullable Handler handler) {
        if (listener != null) {
            Looper looper = handler != null ? handler.getLooper() :
                    mCreatorLooper != null ? mCreatorLooper : Looper.getMainLooper();
            mOnFrameAvailableHandler = new Handler(looper, null, true /*async*/) {
                @Override
                public void handleMessage(Message msg) {
                    listener.onFrameAvailable(SurfaceTexture.this);
                }
            };
        } else {
            mOnFrameAvailableHandler = null;
        }
    }

当监听器回调 onFrameAvailable() 时,监听者可以请求 OpenGL 进行绘制了,这时候它需要做如下两个操作:

	/**
	* 将纹理图像更新为图像流中的最新帧。只能在拥有纹理的 OpenGL ES 上下文在调用线程上
	* 处于活动状态时调用此方法。它将隐式地将其纹理绑定到 GL_TEXTURE_EXTERNAL_OES 纹理目标
	*/
	public void updateTexImage() {
        nativeUpdateTexImage();
    }

	/**
	* 检索与最近一次调用 updateTexImage 设置的纹理图像相关的 4x4 纹理坐标转换矩阵。该转换矩阵
	* 将形式为(s, t, 0, 1)的 2D 齐次纹理坐标(其中 s 和 t 在包含区间[0, 1]内)映射到应该用于
	* 从纹理中采样该位置的纹理坐标。在此转换范围之外对纹理进行采样是未定义的。矩阵按列主序存储,
	* 因此可以直接通过 glLoadMatrixf 或 glUniformMatrix4fv 函数传递给 OpenGL ES。
	*
	* 如果底层缓冲区有相关的裁剪区域,转换还将包括一个轻微的缩放,以在裁剪边缘周围切掉一个 1 像素的边框。
	* 这确保在进行双线性采样时,GPU 不会访问缓冲区有效区域之外的纹素,从而避免在缩放时出现任何采样伪影。
	*
	* 参数:
	* mtx - 存储 4x4 矩阵的数组。数组必须恰好有16个元素。
	*/
	public void getTransformMatrix(float[] mtx) {
        if (mtx.length != 16) {
            throw new IllegalArgumentException();
        }
        nativeGetTransformMatrix(mtx);
    }

接下来我们再结合具体情境看如何通过 SurfaceTexture 将相机采集到的图像交给 OpenGL 绘制。

2、摄像头预览

摄像头将采集到的图像交给 SurfaceTexture,后者再交由 OpenGL 最终绘制到 GLSurfaceView 上。

2.1 摄像头采集图像

使用 Android 的 Camera API 采集摄像头图像,采集到的数据在数据缓存 mBuffer 和 SurfaceTexture 中各保存一份:

class CameraHelper(
    private val mActivity: Activity,
    private var mCameraId: Int,
    private var mWidth: Int,
    private var mHeight: Int
) : Camera.PreviewCallback {

    private lateinit var mCamera: Camera
    private lateinit var mSurfaceTexture: SurfaceTexture
    private lateinit var mBuffer: ByteArray
//    private var mPreviewCallback: CameraPreviewCallback? = null

    /**
     * 开始摄像头预览
     */
    fun startPreview(surfaceTexture: SurfaceTexture) {
        // 1.保存传入的 SurfaceTexture
        mSurfaceTexture = surfaceTexture

        try {
            // 2.打开摄像头
            mCamera = Camera.open(mCameraId)

            // 3.设置摄像头
            // 3.1 设置摄像头参数
            val param = mCamera.parameters
            // 预览格式为 NV21
            param.previewFormat = ImageFormat.NV21
            // 预览尺寸
            param.setPreviewSize(mWidth, mHeight)
            // 更新摄像头参数
            mCamera.parameters = param

            // 3.2 将摄像头采集到的图像旋转为正方向
//            setPreviewOrientation()

            // 3.3 设置接收预览数据的缓冲区与回调
            // 图像数据缓存,NV21 属于 YUV420,占用大小为 RGB 的一半
            mBuffer = ByteArray(mWidth * mHeight * 3 / 2)
            // 将 mBuffer 添加到预览回调的缓冲队列以接收回调数据
            mCamera.addCallbackBuffer(mBuffer)
            // 设置预览回调
            mCamera.setPreviewCallback(this)

            // 3.4 设置展示预览画面的纹理,这样 SurfaceTexture 中
            // 也有一份图像数据,可以传给 OpenGL 渲染到屏幕上
            mCamera.setPreviewTexture(mSurfaceTexture)

            // 4.开启预览
            mCamera.startPreview()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

注意 3.2 的 setPreviewOrientation() 被我们注释掉了,该方法原本在我们介绍音视频推流 Demo 时是可以保证在 SurfaceHolder 上的预览画面被调整为正向的:

	private fun setPreviewOrientation() {
        // 1.获取使用前置还是后置摄像头
        val cameraInfo = Camera.CameraInfo()
        Camera.getCameraInfo(mCameraId, cameraInfo)

        // 2.获取手机的旋转方向,与 Activity 旋转方向一致,并根据屏幕方向获取角度
        val degree = when (mActivity.windowManager.defaultDisplay.rotation) {
            Surface.ROTATION_0 -> 0
            Surface.ROTATION_90 -> 90
            Surface.ROTATION_180 -> 180
            Surface.ROTATION_270 -> 270
            else -> 0
        }

        // 3.计算摄像头需要旋转的角度
        var result: Int
        if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
            result = (cameraInfo.orientation + degree) % 360
            result = (360 - result) % 360 // compensate the mirror
        } else {
            // back-facing
            result = (cameraInfo.orientation - degree + 360) % 360
        }

        // 4.将旋转角度设置给 Camera
        mCamera.setDisplayOrientation(result)
    }

但是现在是将预览图像传入 SurfaceTexture 交给 OpenGL 绘制的缘故,该代码无法调正预览图像。因此我们不在 CameraHelper 中对图像进行调整了,而是在最后绘制的时候通过矩阵将图像调正。

2.2 自定义 GLSurfaceView

OpenGL 会把图像绘制在 GLSurfaceView 上,我们需要自定义一个,让其占满整个布局:

class FilterSurfaceView(context: Context?, attrs: AttributeSet?) : GLSurfaceView(context, attrs) {


    init {
        // 1.设置 EGL 版本
        setEGLContextClientVersion(2)
        // 2.设置渲染器
        setRenderer(GLRender(this))
        // 3.设置渲染模式为按需渲染
        renderMode = RENDERMODE_WHEN_DIRTY
    }
}

解释:

  1. EGL 是上层代码与显卡交互的中间件,它会启动 GLThread 回调 Renderer 的 onSurfaceCreated()、onSurfaceChanged()、onDrawFrame() 方法
  2. 渲染模式有两种,RENDERMODE_WHEN_DIRTY 是按需渲染,有需要渲染的帧数据到来时通过 GLSurfaceView.requestRender() 触发渲染;RENDERMODE_CONTINUOUSLY 是持续渲染,比如每隔 16ms 自动渲染一次,如果没有要更新的帧则显示上一帧(如果经历了很多个 16ms 都没有更新,就会表现为 UI 上的卡顿)

2.3 自定义渲染器

如何控制 OpenGL 对 GLSurfaceView 进行渲染呢?实际上面已经给出了答案,就是通过渲染器 GLRenderer。

GLRenderer 的实现思路:

  • 持有 CameraHelper 控制摄像头采集图像
  • 由于 CameraHelper 采集的图像需要通过 SurfaceTexture 传递给 OpenGL 进行渲染,因此:
    • 需要让 OpenGL 生成一个纹理 ID 作为 SurfaceTexture 的创建参数(即纹理 ID 与 SurfaceTexture 绑定)
    • 将 SurfaceTexture 传给 CameraHelper 后,需要实现 SurfaceTexture.OnFrameAvailableListener 接口,这样当摄像头采集的数据传递给 SurfaceTexture 时,会通过该接口把数据回调过来,然后触发渲染
  • 实现自定义渲染器必须要实现的接口 GLSurfaceView.Renderer,在三个接口方法内实现各自的工作:
    • onSurfaceCreated():对 CameraHelper、SurfaceTexture、ScreenFilter 进行初始化
    • onSurfaceChanged():开启摄像头预览、设置 ScreenFilter 规格
    • onDrawFrame():进行绘制,当然为了解耦,具体的绘制工作是由 ScreenFilter 完成的

以下是实现代码:

class GLRender(private val mGLSurfaceView: GLSurfaceView) : GLSurfaceView.Renderer,
    SurfaceTexture.OnFrameAvailableListener {

    private lateinit var mCameraHelper: CameraHelper
    private lateinit var mTextureIds: IntArray
    private lateinit var mSurfaceTexture: SurfaceTexture
    private lateinit var mScreenFilter: ScreenFilter
    private val mMatrix: FloatArray = FloatArray(16)

    // GLSurfaceView.Renderer start
    /**
     * 主要进行初始化工作
     */
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // 1.初始化 CameraHelper
        mCameraHelper = CameraHelper(
            mGLSurfaceView.context as Activity,
            Camera.CameraInfo.CAMERA_FACING_FRONT,
            CameraHelper.WIDTH,
            CameraHelper.HEIGHT
        )

        // 2.初始化 SurfaceTexture
        // 2.1 先为 SurfaceTexture 生成纹理 ID
        mTextureIds = IntArray(1)
        // 生成纹理 ID,参数依次为纹理 ID 数组长度、纹理 ID 数组、数组偏移量
        GLES20.glGenTextures(mTextureIds.size, mTextureIds, 0)

        // 2.2 创建 SurfaceTexture
        mSurfaceTexture = SurfaceTexture(mTextureIds[0])

        // 2.3 为 SurfaceTexture 设置数据监听,当有视频帧可用时会回调 onFrameAvailable()
        mSurfaceTexture.setOnFrameAvailableListener(this)

        // 3.创建 ScreenFilter 以进行图像绘制
        mScreenFilter = ScreenFilter(mGLSurfaceView.context)
    }

    /**
     * Surface 准备就绪后开启摄像头预览并设置 OpenGL 的绘制视窗
     */
    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        // 开启摄像头预览
        mCameraHelper.startPreview(mSurfaceTexture)

        // 设置 OpenGL 的绘制视窗
        mScreenFilter.onReady(width, height)
    }

    override fun onDrawFrame(gl: GL10?) {
        // 1.清空屏幕为黑色
        GLES20.glClearColor(0f, 0f, 0f, 0f)
        // 设置清理哪一个缓冲区
        // GL_COLOR_BUFFER_BIT 颜色缓冲区
        // GL_DEPTH_BUFFER_BIT 深度缓冲区
        // GL_STENCIL_BUFFER_BIT 模型缓冲区
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

        // 2.更新纹理
        // 2.1 更新离屏渲染的 SurfaceTexture 的数据,即获取新的帧
        mSurfaceTexture.updateTexImage()
        // 2.2 获取到新的帧的变换矩阵
        mSurfaceTexture.getTransformMatrix(mMatrix)

        // 3.交给滤镜进行具体的绘制工作
        mScreenFilter.onDrawFrame(mTextureIds[0], mMatrix)
    }
    // GLSurfaceView.Renderer end

    // SurfaceTexture.OnFrameAvailableListener
    override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) {
        // 当 SurfaceTexture 上有新的帧可用时,请求 GLSurfaceView 进行绘制
        mGLSurfaceView.requestRender()
    }
}

再梳理一下数据流向:

  1. CameraHelper 中的 Camera 会将摄像头采集到的数据交给 SurfaceTexture
  2. GLRender 设置了 SurfaceTexture 的 OnFrameAvailableListener 监听接口,当 SurfaceTexture 上有可用数据时,就会回调 OnFrameAvailableListener 的 onFrameAvailable()
  3. 当 GLRender 接收到 onFrameAvailable() 的回调时,就着手进行绘制,通过 SurfaceTexture 的 updateTexImage() 获取到最新的帧,并通过 getTransformMatrix() 获取到这一帧的变换矩阵,交由 ScreenFilter 进行绘制

2.4 ScreenFilter

ScreenFilter 负责控制 OpenGL 进行绘制,大致步骤如下:

  • 编译顶点着色器与片元着色器,创建着色器程序并链接两种着色器
  • 声明 NIO 中的 FloatBuffer 并赋值,用于为着色器中声明的变量赋值
  • 正式绘制时,可以再细分为如下几步:
    • 声明绘制的窗口范围并声明使用着色器程序
    • 渲染,实际上是为着色器中声明的变量传值的过程
    • 激活给定的纹理、将纹理对象与纹理绑定,最后通知 OpenGL 进行绘制

创建着色器

着色器语言时 OpenGL 创建的一门单独的语言,可以先在 AS 中下载 GLSL Support 插件以获取语言高亮提示等支持:

2024-4-30.GLSL Support插件

接下来就可以开始创建着色器了。在 /res/raw 目录下创建顶点着色器 camera_vertex.glsl:

// 顶点坐标,用于确定要绘制的图像的外部轮廓
attribute vec4 vPosition;

// 纹理坐标,接收采样器采样图片的坐标
attribute vec4 vCoord;

// 4 * 4 的变换矩阵,需要将原本的 vCoord(01,11,00,10)与
// 变换矩阵相乘,才能得到 SurfaceTexture 正确的采样坐标
uniform mat4 vMatrix;

// 传给片元着色器的向量
varying vec2 aCoord;

void main() {
    // 顶点坐标赋值给内置变量 gl_Position 作为顶点的最终位置
    gl_Position = vPosition;
    // 将变换后的纹理的 xy 坐标传递给片元着色器,但是部分机型
    // 用上面的方式做有问题,所以要采用下面的兼容模式
//    aCoord = vCoord.xy;
    aCoord = (vMatrix * vCoord).xy;
}

顶点着色器内定义了四个变量:

  • vPosition 是一个四维向量,其中的元素可以是浮点数,通常表示为 (x, y, z, w) 分别表示在 X、Y、Z 和 W 轴上的分量。W 表示位置的齐次坐标或颜色的透明度。vPosition 接收代码传入的 OpenGL 世界坐标系的四个顶点坐标,这样就可以确定要绘制的边界或者说是外部轮廓
  • vCoord 也是一个四维向量,它用于接收 Android 屏幕坐标系的四个顶点的坐标,用于表示顶点的纹理坐标信息
  • vMatrix 是一个 4 * 4 的变换矩阵,它用于接收代码传入的每一帧的变换矩阵,变换操作可能是缩放、旋转或平移
  • aCoord 是一个二维插值向量,用于在顶点着色器和片段着色器之间传递插值后的纹理坐标

顶点着色器定义的 OpenGL 世界坐标系顶点、Android 屏幕坐标系顶点以及进行平移、缩放、旋转的变换矩阵,都用于对图像整体轮廓的操作。而对图像内每个像素点具体是什么颜色,是通过片元着色器内的采样器采样后获得的,然后赋值给内置变量让 OpenGL 知道图像具体的像素内容。二者分工明确,这一点还是要清楚的。

接着创建片元着色器 camera_fragment.glsl:

// 由于是使用 Android 设备的摄像头进行采样,因此不能使用
// 常规的 sampler2D 采样器,而是使用 OpenGL 扩展 
// GL_OES_EGL_image_external,该扩展支持从外部纹理中进行纹理采样
#extension GL_OES_EGL_image_external : require

// 声明本着色器中的 float 是中等精度
precision mediump float;

// 采样点坐标,即从顶点着色器传递过来的插值后的纹理坐标
varying vec2 aCoord;

// 统一变量 vTexture,它是一个外部(扩展)纹理采样器,用于从外部纹理中采样颜色
uniform samplerExternalOES vTexture;

void main() {
    // 通过使用外部纹理采样器 vTexture 和插值后的纹理坐标 aCoord,
    // 从外部纹理中采样对应位置的颜色,并将结果赋值给内置变量 gl_FragColor,
    // 表示该片段的最终颜色
    gl_FragColor = texture2D(vTexture, aCoord);
}

需要注意由于是从 Android 摄像头采集数据,因此片元着色器的采样器使用的是 OpenGL 提供的扩展采样器 samplerExternalOES,而不是针对 OpenGL 内部采样的 texture2D 采样器。

这段片元着色器就是从外部纹理中采样对应纹理坐标的颜色,并将其作为片段的最终颜色进行输出,常用于将外部纹理(如 SurfaceTexture)渲染到屏幕上。

初始化 ScreenFilter

ScreenFilter 的初始化主要是编译、加载并链接两个着色器,然后获取着色器中定义的变量的地址,最后创建 NIO Buffer 准备为着色器变量传值:

class ScreenFilter(context: Context) {

    // OpenGL 的程序 ID
    private var mProgramId = 0
    // 着色器中声明的变量的地址
    private var vPosition = 0
    private var vCoord = 0
    private var vMatrix = 0
    private var vTexture = 0
    // 给着色器中声明的变量传值时所需要的 Buffer
    private val mVertexBuffer: FloatBuffer
    private val mTextureBuffer: FloatBuffer
    // 要进行绘制的 Surface 的宽高
    private var mWidth = 0
    private var mHeight = 0

    init {
        // 1.读取顶点着色器和片元着色器代码
        val vertexSource = ResourceReader.readTextFromRawFile(context, R.raw.camera_vertex)
        val fragmentSource = ResourceReader.readTextFromRawFile(context, R.raw.camera_fragment)

        // 2.编译着色器代码并获取着色器 ID
        val vertexShaderId = ShaderHelper.compileVertexShader(vertexSource)
        val fragmentShaderId = ShaderHelper.compileFragmentShader(fragmentSource)

        // 3.创建着色器程序,并链接顶点和片元着色器
        mProgramId = ShaderHelper.linkProgram(vertexShaderId, fragmentShaderId)

        // 4.获取着色器中声明的对象的地址,后续要通过地址为这些变量赋值
        // 4.1 获取顶点着色器中的属性变量地址
        vPosition = GLES20.glGetAttribLocation(mProgramId, "vPosition")
        vCoord = GLES20.glGetAttribLocation(mProgramId, "vCoord")
        vMatrix = GLES20.glGetUniformLocation(mProgramId, "vMatrix")
        // 4.2 获取片元着色器中变量地址
        vTexture = GLES20.glGetUniformLocation(mProgramId, "vTexture")

        // 5.创建给着色器中声明的变量传值时所需要的 Buffer
        // 5.1 创建顶点坐标 Buffer。顶点坐标,4 个顶点,每个顶点有 XY 两个维度,
        // 每个维度是 4 个字节的 Float,因此总共占 4 * 2 * 4 个字节
        mVertexBuffer = ByteBuffer.allocateDirect(4 * 2 * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
        // 清空一下再赋值
        mVertexBuffer.clear()
        // 传入 OpenGL 世界坐标系的四个顶点坐标,注意顺序
        val vertex = floatArrayOf(
            -1.0f, -1.0f, // 左下
            1.0f, -1.0f, // 右下
            -1.0f, 1.0f, // 左上
            1.0f, 1.0f, // 右上
        )
        mVertexBuffer.put(vertex)

        // 5.2 创建纹理坐标 Buffer
        mTextureBuffer = ByteBuffer.allocateDirect(4 * 2 * 4)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
        mTextureBuffer.clear()
        // 传入 Android 屏幕坐标系的四个顶点,顺序要与 v 中的对应
        val texture = floatArrayOf(
            0.0f, 1.0f, // 左下
            1.0f, 1.0f, // 右下
            0.0f, 0.0f, // 左上
            1.0f, 0.0f, // 右上
        )
        mTextureBuffer.put(texture)
    }

    fun onReady(width: Int, height: Int) {
        mWidth = width
        mHeight = height
    }
}

初始化过程中,有一些 OpenGL 的固定流程被抽取到工具类中了:

  • ResourceReader.readTextFromRawFile():将 raw 目录下声明的着色器文件的内容读取为字符串:

    		fun readTextFromRawFile(context: Context, rawFileId: Int): String {
                val stringBuffer = StringBuffer()
                val buffer = CharArray(2048)
                context.resources.openRawResource(rawFileId).bufferedReader().use {
                    while (it.read(buffer) != -1) {
                        stringBuffer.append(buffer)
                    }
                }
                return stringBuffer.toString()
            }
    
  • ShaderHelper.compileVertexShader() 编译顶点着色器代码,compileFragmentShader() 编译片元着色器代码:

        /**
         * 加载并编译顶点着色器
         * @param shaderCode 顶点着色器代码
         * @return 编译成功返回顶点着色器 ID,否则返回 0
         */
        fun compileVertexShader(shaderCode: String): Int {
            return compileShader(GLES20.GL_VERTEX_SHADER, shaderCode)
        }
      
        /**
         * 加载并编译片元着色器
         * @param shaderCode 片元着色器代码
         * @return 编译成功返回顶点着色器 ID,否则返回 0
         */
        fun compileFragmentShader(shaderCode: String): Int {
            return compileShader(GLES20.GL_FRAGMENT_SHADER, shaderCode)
        }
      
      	/**
           * 加载并编译着色器代码
           * @param type 着色器类型。GL_VERTEX_SHADER 是顶点着色器,GL_FRAGMENT_SHADER 是片元着色器
           * @param code 着色器代码
           * @return 成功返回着色器 Id,失败返回 0
           */
          private fun compileShader(type: Int, code: String): Int {
              // 1.创建着色器
              val shaderId = GLES20.glCreateShader(type)
              if (shaderId == 0) {
                  if (DEBUG) {
                      Log.e(TAG, "创建着色器失败")
                  }
                  return 0
              }
      
              // 2.编译着色器代码
              // 2.1 将源代码绑定到着色器上,加载到 OpenGL 中以编译和执行
              GLES20.glShaderSource(shaderId, code)
              // 2.2 编译着色器中的源代码为可在 GPU 上执行的二进制形式
              GLES20.glCompileShader(shaderId)
              // 2.3 获取编译状态
              val status = IntArray(1)
              GLES20.glGetShaderiv(shaderId, GLES20.GL_COMPILE_STATUS, status, 0)
              // 2.4 判断编译状态
              if (status[0] != GLES20.GL_TRUE) {
                  Log.e(TAG, "Load vertex shader failed:${GLES20.glGetShaderInfoLog(shaderId)}")
                  if (DEBUG) {
                      Log.d(TAG, "着色器代码: \n${code}")
                  }
                  // 删除着色器对象
                  GLES20.glDeleteShader(shaderId)
                  return 0
              }
              return shaderId
          }
    
  • ShaderHelper.linkProgram() 创建着色器程序并链接两种着色器:

    		/**
             * 将顶点着色器和片元着色器链接到 OpenGL 程序中
             *
             * @param vertexShaderId   顶点着色器id
             * @param fragmentShaderId 片元着色器id
             * @return 链接成功则返回 OpenGL 程序 ID,否则返回 0
             */
            fun linkProgram(vertexShaderId: Int, fragmentShaderId: Int): Int {
                // 1.创建着色器程序
                val programId = GLES20.glCreateProgram()
                if (programId == 0) {
                    Log.e(TAG, "创建 OpenGL 程序失败")
                    return 0
                }
    
                // 2.将着色器对象附加到着色器程序上
                GLES20.glAttachShader(programId, vertexShaderId)
                GLES20.glAttachShader(programId, fragmentShaderId)
    
                // 3.链接着色器,将所有添加到 Program 中的着色器链接到一起
                GLES20.glLinkProgram(programId)
    
                // 4.获取并判断链接状态
                val status = IntArray(1)
                GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, status, 0)
                if (status[0] != GLES20.GL_TRUE) {
                    Log.e(TAG, "Link program error:${GLES20.glGetProgramInfoLog(programId)}")
                    // 删除程序
                    GLES20.glDeleteProgram(programId)
                    return 0
                }
    
                // 5.释放已经编译过,但不再需要的着色器对象(以及所占用的资源)
                GLES20.glDeleteShader(vertexShaderId)
                GLES20.glDeleteShader(fragmentShaderId)
    
                return programId
            }
    
            /**
             * 验证程序(开发过程中可用于调试)
             */
            fun validateProgram(programId: Int): Boolean {
                GLES20.glValidateProgram(programId)
                val validateStatus = IntArray(1)
                GLES20.glGetProgramiv(programId, GLES20.GL_VALIDATE_STATUS, validateStatus, 0)
                if (validateStatus[0] != GLES20.GL_TRUE) {
                    Log.e(TAG, "Program validation error:${GLES20.glGetProgramInfoLog(programId)}")
                    return false
                }
                return true
            }
    

然后我们再解释一下 mVertexBuffer 和 mTextureBuffer 的数组为什么要写成代码中的样子。先看下图:

OpenGL 坐标系的顶点与 Android 屏幕坐标系的顶点有红色虚线所表示的对应关系,当代码中使用左下 -> 右下 -> 左上-> 右上的顺序描述 OpenGL 世界坐标系时:

        // OpenGL 四个顶点坐标,注意顺序
        val vertex = floatArrayOf(
            -1.0f, -1.0f, // 左下
            1.0f, -1.0f, // 右下
            -1.0f, 1.0f, // 左上
            1.0f, 1.0f, // 右上
        )

Android 的坐标系也应该按照同样的顺序进行描述,于是默认的 texture 才会声明为:

		// 与顶点的矩阵顺序应该是位置对应的
        val texture = floatArrayOf(
            0.0f, 1.0f, // 左下
            1.0f, 1.0f, // 右下
            0.0f, 0.0f, // 左上
            1.0f, 0.0f, // 右上
        )

绘制图像

GLRender 通过 onDrawFrame() 将纹理 ID 和变换矩阵传给 ScreenFilter 让后者调用 OpenGL 进行绘制:

	fun onDrawFrame(textureId: Int, matrix: FloatArray) {
        // 1.目标窗口的位置和大小,传入的是原点(坐标系以左下角)坐标
        GLES20.glViewport(0, 0, mWidth, mHeight)

        // 2.使用着色器程序
        GLES20.glUseProgram(mProgramId)

        // 3.渲染,实际上是为着色器中声明的变量传值的过程
        // 3.1 为顶点坐标赋值
        // NIO Buffer 要养成使用前先移动到 0 的习惯
        mVertexBuffer.position(0)
        // 传值,将 mVertexBuffer 中的值传入到 vPosition 起始的地址中。2 表示是 XY 两个维度
        GLES20.glVertexAttribPointer(vPosition, 2, GLES20.GL_FLOAT, false, 0, mVertexBuffer)
        // 传完值后要激活
        GLES20.glEnableVertexAttribArray(vPosition)

        // 3.2 为纹理坐标赋值
        mTextureBuffer.position(0)
        GLES20.glVertexAttribPointer(vCoord, 2, GLES20.GL_FLOAT, false, 0, mTextureBuffer)
        GLES20.glEnableVertexAttribArray(vCoord)

        // 3.3 为变换矩阵赋值
        GLES20.glUniformMatrix4fv(vMatrix, 1, false, matrix, 0)

        // 4.进行绘制
        // 4.1 激活 textureId 所表示的纹理
        GLES20.glActiveTexture(textureId)
        // 4.2 将 GL_TEXTURE_EXTERNAL_OES 所表示的用于处理外部纹理的纹理对象与纹理绑定
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
        // 4.3 将纹理单元索引 0 绑定到采样器变量 vTexture 上
        // vTexture 是片元着色器中声明的采样器 uniform samplerExternalOES vTexture
        GLES20.glUniform1i(vTexture, 0)
        // 4.4 通知 OpenGL 绘制。从第 0 个开始,一共 4 个点
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
    }

在开始绘制之前要先确定绘制区域并声明使用着色器程序,然后就把顶点坐标、纹理坐标以及变换矩阵传给顶点着色器中声明的对应的变量,最后激活纹理、进行绑定并通知 OpenGL 绘制。

处理坐标矩阵

现在运行 Demo 可以看到预览画面,但是后置摄像头的预览画面是相对于正向有个逆时针 90° 旋转的:

2024-4-30.OpenGL摄像头旋转缩小

需要将其顺时针旋转 90° 才能调正:

2024-3-21.OpenGL后置摄像头调正示意图

此时 Android 屏幕坐标系左下角为 (1,1),右下角为 (1,0),所以将后置图像调正的矩阵 texture 才会为:

		// 后摄:顺时针旋转 90° 才是正的
        var texture = floatArrayOf(
            1.0f, 1.0f, // 左下
            1.0f, 0.0f, // 右下
            0.0f, 1.0f, // 左上
            0.0f, 0.0f // 右上
        )

同样的道理,假如前置摄像头图像需要逆时针旋转 90° 再取一个镜像才是正常的:

2024-3-21.OpenGL前置摄像头调正示意图改

那么调整前置摄像头的矩阵为:

		// 前摄:逆时针旋转 90° 才是正的
        t = floatArrayOf(
            0.0f, 0.0f, // 左下
            0.0f, 1.0f, // 右下
            1.0f, 0.0f, // 左上
            1.0f, 1.0f // 右上
        )
        // 前摄还需要再取个镜像
        t = floatArrayOf(
            0.0f, 1.0f, // 左下
            0.0f, 0.0f, // 右下
            1.0f, 1.0f, // 左上
            1.0f, 0.0f // 右上
        )

不同的手机、不同的系统以及 OpenGL 版本的实现可能不同,因此获取到的前后置摄像头旋转的方式也可能不同,特别是我们在 CameraHelper 中没有执行调正预览画面的 setPreviewOrientation(),这些都是造成 OpenGL 绘制的画面呈现不同方向的影响因素。因此我们要明白矩阵调整的原理,才能应付各种情况。

3、实现 FBO 离屏渲染

刚刚我们是把摄像头采集的数据直接绘制到 SurfaceTexture 上,这样做有一个非常明显的弊端就是假如我要为摄像头采集的图像添加各种滤镜、美颜效果时,就只能在 ScreenFilter 中区分各种滤镜效果再作出相应处理,比较混乱,也不满足单一职责原则:

2024-4-9.OpenGL摄像头渲染单独

因此我们考虑对每一种特效单独做一个 Filter 进行处理,示意图如下:

2024-4-9.OpenGL摄像头渲染分离

也即开始是摄像头采集到的原始图像,最后是展现在屏幕上的图像,中间可以添加若干个滤镜效果,当然这些效果并不能直接显示在屏幕上,而是通过 FBO 缓存,经过一层一层的传递,传到最终的 ScreenFilter 才得以显示在屏幕上。

接下来就开始对原有的项目结构进行改造。

3.1 抽取 Filter 基类

首先要定义 Base 着色器:

  • 顶点着色器 base_vertex.glsl:

    // 顶点坐标,确定要画的形状
    attribute vec4 vPosition;
    
    // 纹理坐标,接收采样器采样图片的坐标
    attribute vec2 vCoord;
    
    // 传给片元着色器的像素点
    varying vec2 aCoord;
    
    void main() {
        // 顶点坐标赋值给内置变量 gl_Position
        gl_Position = vPosition;
        // 适配的矩阵相乘操作在 camera_vertex 中做过了
        aCoord = vCoord;
    }
    
  • 片元着色器 base_fragment.glsl:

    // 声明 float 是中等精度的
    precision mediump float;
    
    // 采样点坐标
    varying vec2 aCoord;
    
    // 采样器,由于接收的纹理数据是上一级 Filter 传过来的,
    // 因此使用普通的 2D 采样机就可以了
    uniform sampler2D vTexture;
    
    void main() {
        // texture2D 采集 aCoord 的像素赋值给 gl_FragColor
        gl_FragColor = texture2D(vTexture, aCoord);
    }
    

然后定义滤镜基类 BaseFilter,将 Filter 的通用操作抽取到 BaseFilter 中,主要工作是对着色器程序初始化以及对两个 Base 着色器的绘制工作:

open class BaseFilter(
    context: Context,
    vertexSourceId: Int,
    fragmentSourceId: Int
) {

    // OpenGL 的程序 ID
    protected var mProgramId = 0

    // 着色器中声明的变量的地址
    protected var vPosition = 0
    protected var vCoord = 0
    protected var vMatrix = 0
    protected var vTexture = 0

    // 给着色器中声明的变量传值时所需要的 Buffer
    protected val mVertexBuffer: FloatBuffer
    protected val mTextureBuffer: FloatBuffer

    // 要进行绘制的 Surface 的宽高
    protected var mWidth = 0
    protected var mHeight = 0

    init {
        // 1.读取顶点着色器和片元着色器代码
        val vertexSource = ResourceReader.readTextFromRawFile(context, vertexSourceId)
        val fragmentSource = ResourceReader.readTextFromRawFile(context, fragmentSourceId)

        // 2.编译着色器代码并获取着色器 ID
        val vertexShaderId = ShaderHelper.compileVertexShader(vertexSource)
        val fragmentShaderId = ShaderHelper.compileFragmentShader(fragmentSource)

        // 3.创建着色器程序,并链接顶点和片元着色器
        mProgramId = ShaderHelper.linkProgram(vertexShaderId, fragmentShaderId)

        // 4.获取着色器中声明的对象的地址,后续要通过地址为这些变量赋值
        // 4.1 获取顶点着色器中的属性变量地址
        vPosition = glGetAttribLocation(mProgramId, "vPosition")
        vCoord = glGetAttribLocation(mProgramId, "vCoord")
        vMatrix = glGetUniformLocation(mProgramId, "vMatrix")
        // 4.2 获取片元着色器中变量地址
        vTexture = glGetUniformLocation(mProgramId, "vTexture")

        // 5.创建给着色器中声明的变量传值时所需要的 Buffer
        // 5.1 创建顶点坐标 Buffer
        // 传入 OpenGL 世界坐标系的四个顶点坐标,注意顺序
        val vertex = floatArrayOf(
            -1.0f, -1.0f, // 左下
            1.0f, -1.0f, // 右下
            -1.0f, 1.0f, // 左上
            1.0f, 1.0f, // 右上
        )
        mVertexBuffer = BufferHelper.getFloatBuffer(vertex)

        // 5.2 创建纹理坐标 Buffer
        // 传入 Android 屏幕坐标系的四个顶点,顺序要与 v 中的对应
        val texture = floatArrayOf(
            0.0f, 1.0f, // 左下
            1.0f, 1.0f, // 右下
            0.0f, 0.0f, // 左上
            1.0f, 0.0f, // 右上
        )
        mTextureBuffer = BufferHelper.getFloatBuffer(texture)

        initCoordinator()
    }

    /**
     * 初始化坐标系的函数,子类有需要可以重写
     */
    protected open fun initCoordinator() {

    }

    open fun onReady(width: Int, height: Int) {
        mWidth = width
        mHeight = height
    }

    open fun onDrawFrame(textureId: Int): Int {
        // 1.目标窗口的位置和大小,传入的是原点(坐标系以左下角)坐标
        glViewport(0, 0, mWidth, mHeight)

        // 2.使用着色器程序
        glUseProgram(mProgramId)

        // 3.渲染,实际上是为着色器中声明的变量传值的过程
        // 3.1 为顶点坐标赋值
        // NIO Buffer 要养成使用前先移动到 0 的习惯
        mVertexBuffer.position(0)
        // 传值,将 mVertexBuffer 中的值传入到 vPosition 起始的地址中。2 表示是 XY 两个维度
        glVertexAttribPointer(vPosition, 2, GL_FLOAT, false, 0, mVertexBuffer)
        // 传完值后要激活
        glEnableVertexAttribArray(vPosition)

        // 3.2 为纹理坐标赋值
        mTextureBuffer.position(0)
        glVertexAttribPointer(vCoord, 2, GL_FLOAT, false, 0, mTextureBuffer)
        glEnableVertexAttribArray(vCoord)

        // 矩阵操作属于摄像头的特殊操作,因此放到 CameraFilter 中而不是 BaseFilter 中...

        // 4.进行绘制
        // 4.1 激活 textureId 所表示的纹理
        glActiveTexture(textureId)
        // 4.2 将 2D 纹理对象与纹理绑定
        glBindTexture(GL_TEXTURE_2D, textureId)
        // 4.3 将纹理单元索引 0 绑定到采样器变量 vTexture 上
        glUniform1i(vTexture, 0)
        // 4.4 通知 OpenGL 绘制。从第 0 个开始,一共 4 个点
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)

        return textureId
    }

    open fun release() {
        glDeleteProgram(mProgramId)
    }
}

基本上是 copy 了原来的 ScreenFilter,做出了如下几点改动:

  1. 直接导入 GLES20 的所有内容 import android.opengl.GLES20.*,避免使用方法和常量时出现过多的 GLES20 前缀,后续的其他代码也做同样处理

  2. init 的 5.1 和 5.2 创建 NIO Buffer 的操作抽取到工具类中:

    		fun getFloatBuffer(vertex: FloatArray): FloatBuffer {
                val buffer = ByteBuffer.allocateDirect(vertex.size * 4)
                    .order(ByteOrder.nativeOrder())
                    .asFloatBuffer()
                buffer.put(vertex)
                buffer.position(0)
                return buffer
            }
    
  3. init 的 5.2 让 texture 取的是 Android 屏幕坐标系的原始坐标,通过旋转矩阵调整预览图像角度的操作,是应该在 CameraFilter 中实现的,不应该在基类中实现

  4. 增加 initCoordinator() 用于上一点提到的坐标系初始化,CameraFilter 可以重写该方法以实现旋转摄像头的操作

  5. onDrawFrame() 需要返回纹理 ID,因为每个 Filter 都需要接收纹理 ID,在绘制完毕后将带有自己绘制内容的纹理 ID 传给下一个 Filter

  6. onDrawFrame() 内移除了 3.3 为变换矩阵赋值的操作,因为 BaseFilter 没有旋转摄像头的操作,在 CameraFilter 里才有,因此变换矩阵也应该移到 CameraFilter 中

这样将公共操作抽取到基类后,ScreenFilter 的内容就很轻了:

class ScreenFilter(context: Context) : BaseFilter(context, R.raw.base_vertex, R.raw.base_fragment) {

}

3.2 CameraFilter

CameraFilter 获取摄像头原始数据并绘制到 FBO 上,着色器就是原来的 camera_vertex 和 camera_fragment,无需改动,直接 CameraFilter 的实现,先是准备工作:

class CameraFilter(context: Context) :
    BaseFilter(context, R.raw.camera_vertex, R.raw.camera_fragment) {

    // FBO 对象,是一个帧缓冲区
    private lateinit var mFrameBuffer: IntArray
    // FBO 纹理 ID
    private lateinit var mFrameBufferTextures: IntArray
    // 变换矩阵
    private lateinit var mTransformMatrix: FloatArray

    override fun onReady(width: Int, height: Int) {
        super.onReady(width, height)

        // 1.创建 FBO 对象
        mFrameBuffer = IntArray(1)
        glGenFramebuffers(1, mFrameBuffer, 0)

        // 2.为 FBO 生成纹理并做相应配置
        mFrameBufferTextures = IntArray(1)
        TextureHelper.generateTextures(mFrameBufferTextures)

        // 绑定 FBO 纹理
        glBindTexture(GL_TEXTURE_2D, mFrameBufferTextures[0])

        // 3.生成 2D 纹理图像
        glTexImage2D(
            GL_TEXTURE_2D, // 要绑定的纹理目标
            0, // level 一般为 0
            GL_RGBA, // 纹理图像内部处理的格式指定为 RGBA
            width, height, // 宽高
            0, // 边界
            GL_RGBA, // 纹理图像格式指定为 RGBA
            GL_UNSIGNED_BYTE, // 无符号字节类型
            null // 像素
        )

        // 4.绑定 FBO 与纹理
        glBindFramebuffer(GL_FRAMEBUFFER, mFrameBuffer[0])
        glFramebufferTexture2D(
            GL_FRAMEBUFFER, // 纹理目标
            GL_COLOR_ATTACHMENT0, // 颜色附件
            GL_TEXTURE_2D, // 纹理类型
            mFrameBufferTextures[0], // 纹理对象
            0 // 多级渐远纹理级别
        )

        // 5.解绑
        glBindFramebuffer(GL_FRAMEBUFFER, 0)
        glBindTexture(GL_TEXTURE_2D, 0)
    }
        
    /**
     * 如果 CameraHelper.startPreview() 内开启了 setPreviewOrientation()
     * 这里不用重写 initCoordinator() 调整 mTextureBuffer 直接就是正向的
     */
    override fun initCoordinator() {
        // 后摄:逆时针旋转 90° 才是正的(注意不是之前介绍的顺时针了)
        val texture = floatArrayOf(
            0.0f, 0.0f, // 左下
            0.0f, 1.0f, // 右下
            1.0f, 0.0f, // 左上
            1.0f, 1.0f // 右上
        )
        mTextureBuffer.clear()
        mTextureBuffer.put(texture)
    }
}

其中 TextureHelper 是纹理工具类:

class TextureHelper {

    companion object {

        // 生成 FBO 纹理并配置
        fun generateTextures(textures: IntArray) {
            // 第三个参数 offset 是指从数组的第几个开始生成纹理
            glGenTextures(textures.size, textures, 0)
            textures.forEach { texture ->
                // 1.绑定纹理,绑定后才能进行纹理操作
                glBindTexture(GL_TEXTURE_2D, texture)

                // 2.配置纹理
                // 2.1 设置纹理过滤参数
                // 纹理贴到坐标系中可能会大也可能会小,这时候要设置大了或者小了应该如何缩放
                glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) // 最近点
                glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) // 线性采样

                // 2.2 设置纹理的环绕方式
                // 2.2.1 设置S轴(相当于X轴)的环绕方式,GL_REPEAT纹理超出坐标范围则重复拉伸(平铺)
                glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
                // 2.2.2 设置T轴(相当于Y轴)的环绕方式,GL_CLAMP_TO_EDGE纹理超出坐标范围则截取拉伸(边缘拉伸)
                glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

                // 3.解绑纹理
                glBindTexture(GL_TEXTURE_2D, 0)
            }
        }
    }
}

绘制的时候要注意接收方法参数的纹理 ID,但是返回的应该是 CameraFilter 使用的 FBO 的纹理 ID:

override fun onDrawFrame(textureId: Int): Int {
        // 1.设置绘制视窗的起点与宽高范围
        glViewport(0, 0, mWidth, mHeight)

        // 2.使用着色器程序
        glUseProgram(mProgramId)

        // 3.绑定 FBO,因为要先渲染到 FBO 缓存中,而不是直接绘制到屏幕上
        glBindFramebuffer(GL_FRAMEBUFFER, mFrameBuffer[0])

        // 4.渲染,实际上是传值过程
        // 4.1 顶点坐标
        // 先处理顶点数据,养成使用前先移动到 0 位置
        mVertexBuffer.position(0)
        // 传值,将 mVertexBuffer 中的值传入到 vPosition 起始的地址中。2 是 XY 二维
        glVertexAttribPointer(vPosition, 2, GL_FLOAT, false, 0, mVertexBuffer)
        // 传完值后要激活
        glEnableVertexAttribArray(vPosition)

        // 4.2 纹理坐标
        mTextureBuffer.position(0)
        glVertexAttribPointer(vCoord, 2, GL_FLOAT, false, 0, mTextureBuffer)
        glEnableVertexAttribArray(vCoord)

        // 4.3 变换矩阵
        glUniformMatrix4fv(vMatrix, 1, false, mTransformMatrix, 0)

        // 5.绘制
        // 5.1 激活图层
        glActiveTexture(GL_TEXTURE0)

        // 5.2 绑定纹理
        glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)

        // 5.3 传递参数
        glUniform1i(vTexture, 0)

        // 5.4 通知 OpenGL 绘制。从第 0 个开始,一共 4 个点,每个点与前两个点组成三角形进行绘制
        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)

        // 6.解绑
        glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
        glBindFramebuffer(GL_FRAMEBUFFER, 0)

        return mFrameBufferTextures[0]
    }

    fun setMatrix(matrix: FloatArray) {
        mTransformMatrix = matrix
    }

3.3 渲染器

最后修改渲染器,定义所有滤镜,并在 Surface 创建时创建滤镜对象:

class GLRender(private val mGLSurfaceView: GLSurfaceView) : GLSurfaceView.Renderer,
    SurfaceTexture.OnFrameAvailableListener {
        
    private lateinit var mScreenFilter: ScreenFilter
    private lateinit var mCameraFilter: CameraFilter
        
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        ...

        // 3.创建滤镜对象
        mScreenFilter = ScreenFilter(mGLSurfaceView.context)
        mCameraFilter = CameraFilter(mGLSurfaceView.context)
    }
}

Surface 发生变化时将宽高同步给滤镜对象:

	override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        // 开启摄像头预览
        mCameraHelper.startPreview(mSurfaceTexture)

        // 设置 OpenGL 的视窗
        mCameraFilter.onReady(width, height)
        mScreenFilter.onReady(width, height)
    }

绘制时采用责任链模式,让链上的滤镜逐个绘制:

override fun onDrawFrame(gl: GL10?) {
        // 1.清空屏幕为黑色
        GLES20.glClearColor(0f, 0f, 0f, 0f)
        // 设置清理哪一个缓冲区
        // GL_COLOR_BUFFER_BIT 颜色缓冲区
        // GL_DEPTH_BUFFER_BIT 深度缓冲区
        // GL_STENCIL_BUFFER_BIT 模型缓冲区
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

        // 2.更新纹理
        // 2.1 更新离屏渲染的 SurfaceTexture 的数据,即获取新的帧
        mSurfaceTexture.updateTexImage()
        // 2.2 获取到新的帧的变换矩阵
        mSurfaceTexture.getTransformMatrix(mMatrix)

        // 3.交给滤镜进行具体的绘制工作
        mCameraFilter.setMatrix(mMatrix)
        val textureId = mCameraFilter.onDrawFrame(mTextureIds[0])
        mScreenFilter.onDrawFrame(textureId)
    }

在 CameraFilter 和 ScreenFilter 之间可以添加若干滤镜进行绘制,从而实现各种滤镜效果,后续我们会介绍相关内容。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/595140.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【XR806开发板试用】XR806与鸿蒙,创建任务,串口转发TCPServer收到的数据

很荣幸获得评测开发板的机会&#xff0c;XR806的程序资料做的还是挺不错的。 目标&#xff1a; 1、学习用鸿蒙创建2个任务&#xff1b; 2、创建TCP Server收发数据。 任务ledThread&#xff1a;LED每秒亮灭一次&#xff0c;代表程序在运行。 任务MainThread&#xff1a;创建TCP…

Leetcode—377. 组合总和 Ⅳ【中等】

2024每日刷题&#xff08;124&#xff09; Leetcode—377. 组合总和 Ⅳ 算法思想 实现代码 class Solution { public:int combinationSum4(vector<int>& nums, int target) {vector<unsigned long long>dp(target 1);dp[0] 1;for(int i 1; i < target;…

echarts柱状图实现左右横向对比

实现效果如上图 其实是两组数据&#xff0c;其中一组数据改为负数&#xff0c;然后 在展示的时候&#xff0c;在将负数取反 第一处修改坐标轴 xAxis: [{type: value,axisLabel: {formatter: function (value) {if (value < 0) {return -value;}else{return value;}}}}], 第…

如何修改图片大小?调整图片大小的几个方法介绍

当我们在不同的应用场景中使用图片的时候&#xff0c;常常会需要去调整图片尺寸来适应不同的要求&#xff0c;还有图片体积大小也会有要求&#xff0c;这时候就需要用到我们今天分享的这款图片在线处理工具了&#xff0c;不管是图片改大小或者图片压缩它都能快速解决&#xff0…

LVGL移植到STM32F4

1、LVGL简介 LittlevGL是一个免费的开源图形库&#xff0c;提供了创建嵌入式GUI所需的一切&#xff0c;具有易于使用的图形元素、漂亮的视觉效果和低内存占用。 1.1、LVGL特点 强大的构建模组&#xff1a;按钮、图表、列表、滑块、图像等先进的图形&#xff1a;动画、反锯齿…

【热门话题】ElementUI 快速入门指南

&#x1f308;个人主页: 鑫宝Code &#x1f525;热门专栏: 闲话杂谈&#xff5c; 炫酷HTML | JavaScript基础 ​&#x1f4ab;个人格言: "如无必要&#xff0c;勿增实体" 文章目录 ElementUI 快速入门指南环境准备安装 ElementUI创建 Vue 项目安装 ElementUI 基…

自然语言(NLP)

It’s time for us to learn how to analyse natural language documents, using Natural Language Processing (NLP). We’ll be focusing on the Hugging Face ecosystem, especially the Transformers library, and the vast collection of pretrained NLP models. Our proj…

STM32单片机实战开发笔记-独立看门狗IWDG

嵌入式单片机开发实战例程合集&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/11av8rV45dtHO0EHf8e_Q0Q?pwd28ab 提取码&#xff1a;28ab IWDG模块测试 1、功能描述 STM32F10X内置两个看门狗&#xff0c;提供了更高的安全性&#xff0c;时间的精确下性和使用的灵活性…

微信答题链接怎么做_新手也能快速上手制作

在数字营销日新月异的今天&#xff0c;如何有效吸引用户参与、提升品牌曝光度&#xff0c;成为了每一个营销人都在思考的问题。而微信答题链接&#xff0c;作为一种新兴的互动营销方式&#xff0c;正以其独特的魅力&#xff0c;在营销界掀起一股新的热潮。今天&#xff0c;就让…

第三节课,前端

一、参考链接&#xff1b; 总 知识星球 | 深度连接铁杆粉丝&#xff0c;运营高品质社群&#xff0c;知识变现的工具 分 2022-03-18 星球直播笔记-用户中心&#xff08;下&#xff09; 语雀 二、登录 2.1登录网址 2.2前端页面修改 2.1 页面修改 2.2 页脚的超链接 网址&am…

Window如何运行sh文件以及wget指令

Git下载 官网链接如下&#xff1a;https://gitforwindows.org/ 安装就保持一路无脑安装就行&#xff0c;不需要改变安装过程中的任何一个选项。 配置Git 切刀桌面&#xff0c;随便右击屏幕空白处&#xff0c;点open Git Bash here 把这行复制过去&#xff0c;回车&#xff1…

【源码+文档+调试教程】基于微信小程序的电子购物系统的设计与实现

摘 要 由于APP软件在开发以及运营上面所需成本较高&#xff0c;而用户手机需要安装各种APP软件&#xff0c;因此占用用户过多的手机存储空间&#xff0c;导致用户手机运行缓慢&#xff0c;体验度比较差&#xff0c;进而导致用户会卸载非必要的APP&#xff0c;倒逼管理者必须改…

本地的git仓库和远程仓库

文章目录 1. 远程创建仓库2. 关联远程和本地代码3. 推送本地分支到远程 1. 远程创建仓库 2. 关联远程和本地代码 上面创建完后会得到一个git仓库的链接&#xff0c;有SSH或者http的 http://gitlab.xxxxx.local:18080/xxxxx/dvr_avm.git ssh://gitgitlab.xxxxx.local:10022/xx…

COUNT(1)\COUNT(*)\COUNT(列名)到底谁更快

今天来研究一个比较有趣的话题,关于我们平常使用mysql查询数量的到底那种方式查询效率更高的问题 起因 这个问题在我以前的认知里是,按效率从高到低品排序 count(1)>count(列名)>count(*),但是我也注意到过mybatis-plus官方提供的selectCount方法和分页查询时,它的SQL在…

第五十三节 Java设计模式 - 工厂模式

Java设计模式 - 工厂模式 工厂模式是一种创建模式&#xff0c;因为此模式提供了更好的方法来创建对象。 在工厂模式中&#xff0c;我们创建对象而不将创建逻辑暴露给客户端。 例子 在以下部分中&#xff0c;我们将展示如何使用工厂模式创建对象。 由工厂模式创建的对象将是…

监控公司局域网电脑的软件|局域网电脑监控软件哪个好用

想要监控公司局域网电脑&#xff1f;没问题&#xff0c;市面上有一大堆选择等着你&#xff01;每个软件都有它的独门绝技和适用场合&#xff0c;接下来就让我带你看看哪些软件既好用又功能强大吧&#xff01; &#x1f389;OpManager&#xff1a; 这位大佬适合中大型企业&#…

每日算法-java

题目来自蓝桥云 // 这是一个Java程序&#xff0c;用于解决最长不下降子序列问题。 // 问题描述&#xff1a;给定一个整数序列&#xff0c;找到最长的子序列&#xff0c;使得这个子序列是不下降的&#xff08;即相邻的元素不严格递减&#xff09;。 // 程序使用了动态规划的方法…

STM32编译前置条件配置

本文基于stm32f104系列芯片&#xff0c;记录编程代码前需要的操作&#xff1a; 添加库文件 在ST官网下载标准库STM32F10x_StdPeriph_Lib_V3.5.0&#xff0c;解压后&#xff0c;得到以下界面 启动文件 进入Libraries&#xff0c;然后进入CMSIS&#xff0c;再进入CM3&#xff…

这些接口测试工具你一定要知道

接口测试工具 接口测试工具如图&#xff1a; 1.Fiddler 首先&#xff0c;这是一个HTTP协议调试代理工具&#xff0c;说白了就是一个抓http包的工具。web测试和手机测试都能用到这个工具。既然是http协议&#xff0c;这个工具也能支持接口测试。 2.PostMan Postman一款非常流行…

视频号怎么做有收益,上传短视频怎么挣钱

比如说抖音有中视频流量收益&#xff0c;B站有创作激励计划流量收益&#xff0c;如今在微信端不仅有公众号流量主收益&#xff0c; 现在视频号还推出了创造分成流量收益&#xff01; 对于我们普通人来说无异于又一个机会&#xff0c;能不能抓得住就看你能不看懂本Sir今天的这…