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*/) {@Overridepublic 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: Cameraprivate lateinit var mSurfaceTexture: SurfaceTextureprivate lateinit var mBuffer: ByteArray
//    private var mPreviewCallback: CameraPreviewCallback? = null/*** 开始摄像头预览*/fun startPreview(surfaceTexture: SurfaceTexture) {// 1.保存传入的 SurfaceTexturemSurfaceTexture = surfaceTexturetry {// 2.打开摄像头mCamera = Camera.open(mCameraId)// 3.设置摄像头// 3.1 设置摄像头参数val param = mCamera.parameters// 预览格式为 NV21param.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 -> 0Surface.ROTATION_90 -> 90Surface.ROTATION_180 -> 180Surface.ROTATION_270 -> 270else -> 0}// 3.计算摄像头需要旋转的角度var result: Intif (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {result = (cameraInfo.orientation + degree) % 360result = (360 - result) % 360 // compensate the mirror} else {// back-facingresult = (cameraInfo.orientation - degree + 360) % 360}// 4.将旋转角度设置给 CameramCamera.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: CameraHelperprivate lateinit var mTextureIds: IntArrayprivate lateinit var mSurfaceTexture: SurfaceTextureprivate lateinit var mScreenFilter: ScreenFilterprivate val mMatrix: FloatArray = FloatArray(16)// GLSurfaceView.Renderer start/*** 主要进行初始化工作*/override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {// 1.初始化 CameraHelpermCameraHelper = CameraHelper(mGLSurfaceView.context as Activity,Camera.CameraInfo.CAMERA_FACING_FRONT,CameraHelper.WIDTH,CameraHelper.HEIGHT)// 2.初始化 SurfaceTexture// 2.1 先为 SurfaceTexture 生成纹理 IDmTextureIds = IntArray(1)// 生成纹理 ID,参数依次为纹理 ID 数组长度、纹理 ID 数组、数组偏移量GLES20.glGenTextures(mTextureIds.size, mTextureIds, 0)// 2.2 创建 SurfaceTexturemSurfaceTexture = 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.OnFrameAvailableListeneroverride 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 的程序 IDprivate var mProgramId = 0// 着色器中声明的变量的地址private var vPosition = 0private var vCoord = 0private var vMatrix = 0private var vTexture = 0// 给着色器中声明的变量传值时所需要的 Bufferprivate val mVertexBuffer: FloatBufferprivate val mTextureBuffer: FloatBuffer// 要进行绘制的 Surface 的宽高private var mWidth = 0private var mHeight = 0init {// 1.读取顶点着色器和片元着色器代码val vertexSource = ResourceReader.readTextFromRawFile(context, R.raw.camera_vertex)val fragmentSource = ResourceReader.readTextFromRawFile(context, R.raw.camera_fragment)// 2.编译着色器代码并获取着色器 IDval 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 创建纹理坐标 BuffermTextureBuffer = 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 = widthmHeight = 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 vTextureGLES20.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_Positiongl_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_FragColorgl_FragColor = texture2D(vTexture, aCoord);
    }
    

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

open class BaseFilter(context: Context,vertexSourceId: Int,fragmentSourceId: Int
) {// OpenGL 的程序 IDprotected var mProgramId = 0// 着色器中声明的变量的地址protected var vPosition = 0protected var vCoord = 0protected var vMatrix = 0protected var vTexture = 0// 给着色器中声明的变量传值时所需要的 Bufferprotected val mVertexBuffer: FloatBufferprotected val mTextureBuffer: FloatBuffer// 要进行绘制的 Surface 的宽高protected var mWidth = 0protected var mHeight = 0init {// 1.读取顶点着色器和片元着色器代码val vertexSource = ResourceReader.readTextFromRawFile(context, vertexSourceId)val fragmentSource = ResourceReader.readTextFromRawFile(context, fragmentSourceId)// 2.编译着色器代码并获取着色器 IDval 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 = widthmHeight = 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 纹理 IDprivate lateinit var mFrameBufferTextures: IntArray// 变换矩阵private lateinit var mTransformMatrix: FloatArrayoverride 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 一般为 0GL_RGBA, // 纹理图像内部处理的格式指定为 RGBAwidth, height, // 宽高0, // 边界GL_RGBA, // 纹理图像格式指定为 RGBAGL_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: ScreenFilterprivate lateinit var mCameraFilter: CameraFilteroverride 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 之间可以添加若干滤镜进行绘制,从而实现各种滤镜效果,后续我们会介绍相关内容。

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

(论文阅读-优化器)Selectivity Estimation using Probabilistic Models

目录 摘要 一、简介 二、单表估计 2.1 条件独立Condition Independence 2.2 贝叶斯网络Bayesian Networks 2.3 查询评估中的贝叶斯网络 三、Join选择性估计 3.1 两表Join 3.2 概率关系模型 3.3 使用PRMs的选择性估计 四、PRM构建 4.1 评分标准 4.2 参数估计 4.3 结…

堡垒机——网络技术手段

目录 一、简介 1.什么是跳板机 2.跳板机缺陷 3.什么是堡垒机 4.为什么要使用堡垒机 4.1堡垒机设计理念 4.2堡垒机的建设目标 4.3堡垒机的价值 4.4总结 5.堡垒机的分类 6.堡垒机的原理 7.堡垒机的身份认证 8.堡垒机的运维方式常见有以下几种 9.堡垒机其他常见功能…

基于springboot+vue+Mysql的在线动漫信息平台

开发语言&#xff1a;Java框架&#xff1a;springbootJDK版本&#xff1a;JDK1.8服务器&#xff1a;tomcat7数据库&#xff1a;mysql 5.7&#xff08;一定要5.7版本&#xff09;数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/ideaMaven包&#xff1a;…

大数据分析入门之10分钟掌握GROUP BY语法

前言 书接上回大数据分析入门10分钟快速了解SQL。 本篇将会进一步介绍group by语法。 基本语法 SELECT column_name, aggregate_function(column_name) FROM table_name GROUP BY column_name HAVING condition假设我们有students表&#xff0c;其中有id,grade_number,class…

网络文件共享

存储类型分三类 直连式存储&#xff1a;DAS存储区域网络&#xff1a;SAN网络附加存储&#xff1a;NAS 三种存储架构的应用场景 DAS虽然比较古老了&#xff0c;但是还是很适用于那些数据量不大&#xff0c;对磁盘访问速度要求较高的中小企业SAN多适用于文件服务器&#xff0c…

C/C++ BM33 二叉树的镜像

文章目录 前言题目解决方案一1.1 思路阐述1.2 源码 总结 前言 镜像说的好听&#xff0c;无非就是换下节点。 题目 操作给定的二叉树&#xff0c;将其变换为源二叉树的镜像。 数据范围&#xff1a;二叉树的节点数 0 ≤ n ≤ 1000 0≤n≤1000 0≤n≤1000&#xff0c; 二叉树每…

华为ensp中USG6000V防火墙双机热备VRRP+HRP原理及配置

作者主页&#xff1a;点击&#xff01; ENSP专栏&#xff1a;点击&#xff01; 创作时间&#xff1a;2024年5月6日20点26分 华为防火墙双机热备是一种高可用性解决方案&#xff0c;可以将两台防火墙设备组成一个双机热备组&#xff0c;实现主备切换。当主用防火墙出现故障时&…

景源畅信:抖音运营做什么工作内容?

在如今这个信息爆炸的时代&#xff0c;抖音已经成为了人们生活中不可或缺的一部分。无论是消磨时间、获取信息还是展示自我&#xff0c;抖音都扮演着重要的角色。那么&#xff0c;作为抖音运营&#xff0c;他们需要做些什么呢? 一、内容策划与制作 抖音运营的首要任务就是内容…

【动态规划】路径问题

1.不同路径 不同路径 思路&#xff1a; 状态表示 状态转移方程 class Solution { public:int uniquePaths(int m, int n) {// 创建dp表// 初始化// 填表// 返回值vector<vector<int>> dp(m 1, vector<int>(n 1));dp[0][1] 1;for(int i 1; i < m; i…

【数据结构】C++语言实现栈(详细解读)

c语言中的小小白-CSDN博客c语言中的小小白关注算法,c,c语言,贪心算法,链表,mysql,动态规划,后端,线性回归,数据结构,排序算法领域.https://blog.csdn.net/bhbcdxb123?spm1001.2014.3001.5343 给大家分享一句我很喜欢我话&#xff1a; 知不足而奋进&#xff0c;望远山而前行&am…

AI预测体彩排3第3套算法实战化赚米验证第2弹2024年5月6日第2次测试

由于今天白天事情比较多&#xff0c;回来比较晚了&#xff0c;趁着还未开奖&#xff0c;赶紧把预测结果发出来吧~今天是第2次测试~ 2024年5月6日排列3预测结果 6-7码定位方案如下&#xff1a; 百位&#xff1a;2、3、1、5、0、6 十位&#xff1a;4、3、6、8、0、9 个位&#xf…

4.任务创建和删除的API函数

一、简介 二、动态创建任务函数:xTaskCreate() 此函数用于使用动态的方式创建任务&#xff0c;任务的任务控制块以及任务的栈空间所需的内存&#xff0c;均由 FreeRTOS 从 FreeRTOS 管理的堆中分配&#xff0c;若使用此函数&#xff0c;需要在 FreeRTOSConfig.h 文件 中将宏 c…

【智能算法】PID搜索算法(PSA)原理及实现

目录 1.背景2.算法原理2.1算法思想2.2算法过程 3.结果展示4.参考文献5.代码获取 1.背景 2023年&#xff0c;Y Gao受到PID控制理论启发&#xff0c;提出了PID搜索算法&#xff08;PID-based Search Algorithm, PSA&#xff09;。 2.算法原理 2.1算法思想 PID算法是控制领域的…

安卓应用开发(一):工具与环境

开发工具 Android Studio&#xff0c;用于开发 Android 应用的官方集成开发环境 (IDE)。包括以下功能&#xff1a; 基于Gradle的构建系统 gradle是一个项目构建工具&#xff0c;将源工程打包构建为apk 安卓模拟器统一环境代码编辑模拟器实时更新Github集成Lint功能&#xff0…

4+1视图,注意区分类图与对象图

注意区分类图和对象图。对象图标记的是对象名&#xff0c;命名形式 对象名:类名&#xff0c;或者:类名。这里没有出现冒号&#xff0c;表示的是类图。 对象图(object diagram)。 对象图描述一组对象及它们之间的关系。对象图描述了在类图中所建立的事物实例的静态快照。和类图一…

数据结构十一:数组相关经典面试题

本篇博客详细介绍分析数组/顺序表常见的面试题&#xff0c;对于前面所学知识进行一个巩固&#xff0c;同时介绍一些力扣刷题中的一些概念&#xff1a;如&#xff1a;输出型参数等&#xff0c;在刷题中培养自己的编程思维&#xff0c;掌握常见的编程套路&#xff0c;形成题感&am…

嵌入式Linux学习第二天

今天学习linuxC编程。首先要熟悉linux下编写c程序的过程。 编写程序Hello World! 首先创建存放程序的文件夹&#xff0c;如下图所示&#xff1a; 接下来在创建一个文件夹来保存这节要编写的代码。指令&#xff1a;mkdir 3.1 接下来我们要设置VIM编辑器的一些配置&#xff0…

自定义类型②③——联合体和枚举

自定义类型②③——联合体和枚举 1.联合体1.1 联合体类型的声明1.2 联合体的特点1.3 相同成员结构体和联合体的对比1.4 联合体大小的计算1.5 联合体的应用①1.5 联合体的应用② 2. 枚举2.1 枚举类型的声明2.2 枚举类型的特点2.3 枚举的优点 1.联合体 1.1 联合体类型的声明 关…

【C语言】高质量选择题

目录 题目一&#xff1a; 题目二&#xff1a; 题目三&#xff1a; 题目四&#xff1a; 题目五&#xff1a; 题目六&#xff1a; 题目七&#xff1a; 题目八&#xff1a; 题目九&#xff1a; 题目十&#xff1a; 题目十一&#xff1a; 题目十二&#xff1a; 题目十…

腾讯地图商业授权说明一篇文章讲清楚如何操作

最近在使用腾讯地图&#xff0c;发现我要上架应用商店APP需要我有地图的授权书。 认真研究了一下原来腾讯地图现在要收费了&#xff0c;如果你打算以商业目的使用它&#xff0c;比如对第三方用户收费或者进行项目投标等&#xff0c;就需要先获取腾讯位置服务的商业授权许可。申…