Android利用OpenGLES进行通用计算

部署运行你感兴趣的模型镜像

前言

由于公司项目需要做一些3D内容的呈现,学习了关于OpenGL ES的内容。

OpenGL ES (OpenGL for Embedded Systems) 是OpenGL的子集,算是OpenGL的精简版,主要用于移动设备。安卓中用的也是ES版本。本文为了方便起见,所说的OpenGL都是指ES版本。

OpenGL是利用GPU做高并发的计算,让数百万像素的屏幕能快速的显示的正确的图像。

项目中绘制3D的工作总算是完成了,想到近期很火的挖矿、AI等都是用到GPU进行计算,我就想探究现在已掌握部分的OpenGL相关技术,能否用于除绘图外的通用计算。

在Android端使用OpenGL的compute shader加速计算_android studio compute shader-优快云博客

上面这篇是让我相信此可能性的文章,但是文章中的代码不完整,我用里面不完整的代码加上自己脑补里面欠缺的部分代码,无法算出预期的结果。而且要下载其提供的源码竟然需要50积分,实在太贵了~~

于是我花了很多时间找别的资料并尝试,最终完成了通用计算的目标。此篇文章就是整理我这段时间的学习心得,希望想学习这方面技术的人参考本文后能有所收获。

碍于篇幅,此文章也只列出那些关键代码,完整的代码分享在这

https://download.youkuaiyun.com/download/shouhengboy/91866995

意思意思的小积分,算是对我的小鼓励。如果不想付下载分,文中也有一些链接,可下载到能运行的项目代码,自行去下载学习。

正文

一、OpenGL基本纹理绘制

要实现计算的第一步,就是考虑数据要怎么"传进去"给GPU计算?答案是是使用"纹理"。

第一步就先简单的用OpenGL绘制纹理,并传入一些其他数据给他计算,做简单的图像处理。

下面这个系列的文章对我OpenGL的学习过程有很大的帮助,如果你没学过OpenGL的相关技术,可以先参考他的文章

https://blog.youkuaiyun.com/junzia/category_9269184.html

在安卓中最简单的 OpenGL 图像绘制,是用 GLSurfaceView 生成的 EGL 环境。

首先在layout中

<android.opengl.GLSurfaceView
    android:id="@+id/glView"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1"/>

然后在Activity中

GLSurfaceView glView = findViewById(R.id.glView);
glView.setEGLContextClientVersion(2);
glView.setRenderer(renderer);
glView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);

其中,Renderer(渲染器)就是处理图像的关键部分,实现其 onSurfaceCreated、onSurfaceChanged、onDrawFrame 等接口方法,链接着色器代码,将图片"纹理"与相关"计算参数"传入着色器中进行计算,最终就能绘制出处理后的图片。

由于本人已经将创建着色器程序、链接参数等相关操作都封装过的,所以对 Renderer 中代码能直接复制分享的Java代码不多,建议直接参考上面分享的那个系列文章。

下面分享着色器代码

顶点着色器

attribute vec3 a_position;//顶点位置
attribute vec2 a_texCoord;//纹理顶点坐标
varying vec2 v_texCoord;//用于传递给片元着色器的变量

void main() {
    gl_Position = vec4(a_position, 1);
    v_texCoord = a_texCoord;
}

片元着色器

precision mediump float;
varying vec2 v_texCoord; //接收从顶点着色器过来的纹理顶点
uniform sampler2D u_sTexture; //纹理内容数据
uniform mat3 u_colorMatrix; // 色调转换矩阵

void main(){
    vec3 inputPixel = texture2D(u_sTexture, v_texCoord).rgb; //取出纹理颜色
    vec3 resultPixel = u_colorMatrix * inputPixel; // 计算色相旋转
    gl_FragColor = vec4(resultPixel, 1.0); // 设置此片的颜色到gl_FragColor
}

其中,u_sTexture就是"纹理",u_colorMatrix就是"计算参数"。

在安卓中提到图片,一般用的就是 Bitmap 这个类。下面这句就是将Bitmap传入变成纹理。

GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

此处省略一千个细节...

最终实现以下效果

二、离屏渲染读取计算结果

上一步我们用是在 GLSurfaceView 中绘制图像,但如果要用于"纯运算",就需要脱离 View 来调用 OpenGL。

上面那个系列文章中,有一篇"利用EGL后台处理图像",说明了脱离 GLSurfaceView 来创建 EGL 环境的方法,可在脱离 View 的情况下使用 OpenGL。

然后,当 OpenGL 渲染结束后,也就是 glDrawArrays 方法后,我们还需要读取计算的结果,可以使用 glReadPixels 方法读出计算结果。

GLES20.glReadPixels(0, 0, w, h, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, mReadBuf);

最后那个 mReadBuf 是一个 Buffer,例如读整形可以用 IntBuffer,读浮点类型用 FloatBuffer,或是用 ByteBuffer 可读取任何类型的结果。

这里我们先用 IntBuffer 读取

然后创建 Bitmap

Bitmap outBmp = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
// 拷贝读出的结果到 Bitmap 中
outBmp.copyPixelsFromBuffer(mReadBuf);

最后将 Bitmap 放到 ImageView 中显示,得到与第一步中一样的显示效果。

三、OpenGL ES 3.0

接下来的 三 ~ 四 属于过渡阶段,是基于上述步骤一步一步"升级"过来的,实际使用有兼容性问题。但是由于升级过程中有一些知识点,所以我还是详细介绍了。

网上关于OpenGLES的文章,大部分都是用 OpenGLES2.0 的版本,基本所有手机都支持这个版本,并且如果只是用于绘图,这个版本也已经够用了。

但是,我的目标是用在通用计算,需要考虑各种数据格式。其中,最棘手的就是 float 类型。

OpenGL的纹理的颜色,是由 rgba 4个值组成,也就是 4 bytes,然后 float 也是 4 bytes,不过是特殊的数据格式(符号位+指数位+尾数位)。

能否把要计算输入的 float 传入,然后在着色器将 rgba 恢复成 float 数据,计算完成后再把 float 转回 rgba 再渲染输出。之后再用 FloatBuffer 读取数据,得到最终的计算结果?

抱着这个思路,我开始以下尝试。

GLSL语言中也没有类似 ByteBuffer 可以转换各种数据格式的东西,要在 rgba 与 float 两个数据格式间转换,势必用到"位运算"。

在GLSL中添加位运算的代码

vec4 inputPixel = imageLoad(u_inputImage, texelCoord);

// 将纹理颜色值 rgba 取出
// inputPixel.r 得到的值会是 float 0.0 ~ 1.0 间的值,原本是 255,得到的是 1.0,所以下方 * 255 是把原本byte复原回来
int ir = int(inputPixel.r * 255.0);
int ig = int(inputPixel.g * 255.0);
int ib = int(inputPixel.b * 255.0);
int ia = int(inputPixel.a * 255.0);

// 将原本的 4 bytes转成一个int
int ii = (ir & 0xFF) + ((ig & 0xFF) << 8) + ((ib & 0xFF) << 16) + ((ia & 0xFF) << 24);

然后创建程序时报了以下错误

ERROR: 0:17: '&' : bit-wise operator supported in GLSL ES 3.00 and above only

ERROR: 0:17: '<<' : bit-wise operator supported in GLSL ES 3.00 and above only

从上方的错误信息得知,要在GLSL中使用位运算需要 GLSL ES 3.00以上的版本。

3.0版本与2.0版本的语法上有一些差异,所以必须先掌握3.0版本的语法,下方列出我两个版本的着色器代码,对比其差异。

2.0版本

顶点着色器

attribute vec3 a_position;//顶点位置
attribute vec2 a_texCoord;//纹理顶点坐标
varying vec2 v_texCoord;//用于传递给片元着色器的变量

void main() {
    gl_Position = vec4(a_position, 1);
    v_texCoord = a_texCoord;
}

片元着色器

precision mediump float;
varying vec2 v_texCoord; //接收从顶点着色器过来的纹理顶点
uniform sampler2D u_sTexture; //纹理内容数据
uniform mat3 u_colorMatrix; // 色调转换矩阵

void main(){
    vec3 inputPixel = texture2D(u_sTexture, v_texCoord).rgb; //取出纹理颜色
    vec3 resultPixel = u_colorMatrix * inputPixel; // 计算色相旋转
    gl_FragColor = vec4(resultPixel, 1.0); // 设置此片的颜色到gl_FragColor
}

3.0版本

顶点着色器

#version 300 es
layout (location = 0) in vec3 a_position;//顶点位置
layout (location = 1) in vec2 a_texCoord;//纹理顶点坐标
out vec2 v_texCoord;//用于传递给片元着色器的变量

void main() {
    gl_Position = vec4(a_position, 1);
    v_texCoord = a_texCoord;
}

片元着色器

#version 300 es
precision mediump float;
in vec2 v_texCoord; //接收从顶点着色器过来的纹理顶点
uniform sampler2D u_sTexture; //纹理内容数据
uniform mat3 u_colorMatrix; // 色调转换矩阵
out vec4 outColor; //输出显示颜色

void main(){
    vec3 inputPixel = texture(u_sTexture, v_texCoord).rgb; //取出纹理颜色
    vec3 resultPixel = u_colorMatrix * inputPixel; // 计算色相旋转
    outColor = vec4(resultPixel, 1.0);  // 设置此片的颜色到outColor
}

3.0版本主要变更

1. 着色器开头必须以 #version 300 es 开头

2. 传入顶点,由 attribute vec3 改成 layout (location = 0) in vec3

3. 顶点着色器传给片元着色器的参数,由 varying 改成 out 与 in

4. 获取纹理颜色由 texture2D() 改成 texture()

5. 片元着色器输出最终颜色,由固定变量 gl_FragColor = 颜色,改成先声明 out vec4 outColor; (变量名可改) 然后 outColor = 颜色

然后沿用最前面的 Renderer,只是创建程序时传入3.0版本的GLSL代码,得到与前面一样的显示效果,升级3.0的工作就算完成了。

四、执行通用计算(GLSL ES 3.0版本)

升级 GLSL ES 3.00 的代码后,再尝试加入位运算的相关代码,没有再报错了。接下来开始写正式的数据转换、计算的相关代码。

我们以计算以2为底的 log 值为例,计算大数组 float[1000000],利用 GPU 做并发计算。

片元着色器

#version 300 es
precision mediump float;
in vec2 v_texCoord; //接收从顶点着色器过来的纹理顶点
uniform sampler2D u_sTexture; //纹理内容数据
out vec4 outColor; //输出显示颜色

void main(){
    vec4 inputPixel = texture(u_sTexture, v_texCoord); //纹理颜色值
    // 将纹理颜色值 rgba 取出
    // inputPixel.r 得到的值会是 float 0.0 ~ 1.0 间的值,原本是 255,得到的是 1.0,所以下方 * 255 是把原本byte复原回来
    int ir = int(inputPixel.r * 255.0);
    int ig = int(inputPixel.g * 255.0);
    int ib = int(inputPixel.b * 255.0);
    int ia = int(inputPixel.a * 255.0);

    // 将原本的 4 bytes转成一个int
    int ii = (ir & 0xFF) + ((ig & 0xFF) << 8) + ((ib & 0xFF) << 16) + ((ia & 0xFF) << 24);
    // 得到真实传入的 float 值
    float f = intBitsToFloat(ii);
    // 计算以2为底的log值
    f = log(f) / log(2.0);
    // 把计算结果 float 转回 int
    int oi = floatBitsToInt(f);
    // 把输出的rgba转回 0.0~1.0 的 float
    float r = float((oi) & 0xFF) / 255.0;
    float g = float((oi >> 8) & 0xFF) / 255.0;
    float b = float((oi >> 16) & 0xFF) / 255.0;
    float a = float((oi >> 24) & 0xFF) / 255.0;

    outColor = vec4(r, g, b, a);
}

然后,要把 float 数组传进去计算。

我们之前传入纹理数据时,是把图片 Bitmap 透过以下方法传入

GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

然而,现在我们的数据源不是图片而是float数组,改用以下方法将数据传入。

FloatBuffer buffer = FloatBuffer.wrap(srcData);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, srcWidth, srcHeight, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer);

其中,srcWidth、srcHeight 是纹理数据的宽高。我的数组长度是 1000 * 1000,所以宽高就定1000。

渲染完成后,再将数据读出。

FloatBuffer buf = FloatBuffer.allocate(1000000);
GLES20.glReadPixels(0, 0, data.width, data.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf);
float[] res = buf.array();

最后,我们得到了计算结果。

为了验证结果是否正确,我们用 java 的方式计算一下结果,对比两者的差异,并对比两者的耗时。

对比 OpenGL 与 java的计算结果,最大只有 1.9E-6 的计算误差,可以说计算结果完全可靠。

性能对比

这边用 Android SDK 中自带的模拟器来做性能对比(真机有兼容性问题,后面再说),计算 float[1000000] 的log2值100次。

Android 9(API28)

OpenGL:950 ms

Java:2200 ms

Android 15(API35)

OpenGL:1100 ms

Java:11000 ms

可看到利用OpenGL计算有2~10倍的性能提升,可能用模拟器跑Android 15对电脑性能挑战比较大,所以CPU运算速度较慢,但对GPU影响就没那么大。

然后进一步观察日志,可观察到OpenGL运算的主要耗时是在 glReadPixels 这个方法,从传入纹理数据到渲染 glDrawArrays 的耗时只有 1~2ms,再 glReadPixels 后耗时就到10ms。

所以OpenGL计算时,主要的性能瓶颈是在读取计算结果,实际计算(渲染)是一瞬间完成的。也就是如果算法复杂的话,性能提升是会更明显的。

兼容性问题

模拟器已经OK了,接下来用真机试试看,得到的计算结果全是0。

后来发现只要注释掉 f = log(f) / log(2.0); 这一句(也就是不计算),可以得到输出结果,按理说输出应该等于输入(只是把rgba转float再转回rgba),但是发现结果已经与输入不同了(1.0019379, 2.0039062 ...)。

光转换过程就发生了数据偏差,就算能运算结果也不可靠了。

五、计算着色器-图片处理

从OpenGL ES 3.1开始新增了计算着色器(Compute Shader),是用于执行通用计算的,我之前参考的那篇就是介绍使用计算着色器的

在Android端使用OpenGL的compute shader加速计算_android studio compute shader-优快云博客

但是,我参考上面这篇时无法顺利跑出正确的结果。经过多方的搜索,我在Google的官方文档中找到了相关资料。

将脚本迁移到 OpenGL ES 3.1  |  Views  |  Android Developers

其中包括了可运行的示例源码(我上述举的色相旋转图片处理的例子就是从这来的)

https://github.com/android/renderscript-samples/blob/main/RenderScriptMigrationSample/app/src/main/java/com/android/example/rsmigration/GLSLImageProcessor.kt

经过一番学习,梳理官方代码的代码逻辑(还把kotlin转成我习惯的java),最后终于完成了我自己的计算着色器。

我们先用计算着色器来实现上面图片处理的功能

着色器

#version 310 es
layout(std430) buffer; // 官方的demo有,不确定干嘛用的,注释掉也能用
layout (local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

uniform layout (rgba8, binding = 0) readonly highp image2D u_inputImage;
uniform layout (rgba8, binding = 1) writeonly highp image2D u_outputImage;
uniform mat3 u_colorMatrix;

void main() {
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
    vec3 inputPixel = imageLoad(u_inputImage, texelCoord).rgb;
    vec3 resultPixel = u_colorMatrix * inputPixel;
    imageStore( u_outputImage, texelCoord, vec4(resultPixel, 1.0f));
}

对比与3.0版绘制的着色器差异

1. 计算着色器不区分顶点、片元着色器,不用传顶点坐标。数据是直接传到"工作组"中,用这句设置本地工作组大小 layout (local_size_x ... 。

2. 纹理数据由 uniform sampler2D 改成 uniform layout (rgba8, binding = 0) readonly highp image2D,可定义数据类型(rgba8 or other),纹理下标(binding = n),用于传入还是读出(readonl、writeonly)

3. 取当前位置的纹理值,之前是 texture(u_sTexture, v_texCoord) 改成先取得当前位置 ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy) 再读取当前位置的纹理值 imageLoad(u_inputImage, texelCoord)

4. 输出结果之前是声明 out vec2 v_texCoord; 后对 v_texCoord = 结果值 设置颜色;改成把颜色保存到输出纹理 imageStore(u_outputImage, texelCoord, 结果值);

接下来是java的部分

绑定输入图片纹理

GLES31.glGenTextures(1, mRotateTextureHandle, 0); // 生成纹理
GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, mRotateTextureHandle[0]); // 绑定纹理
GLES31.glTexStorage2D(
    GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA8,
    width, height
); // 创建纹理存储区
GLUtils.texSubImage2D(
    GLES31.GL_TEXTURE_2D, 0, 0, 0,
    bitmap
); // 将bitmap数据更新到纹理中
GLES31.glActiveTexture(GLES31.GL_TEXTURE0 + INPUT_TEXTURE_UNIT_INDEX);
if (mRotateTextureHandle[0] != 0) {
    GLES31.glBindImageTexture(
        INPUT_TEXTURE_UNIT_INDEX, // unit
        mRotateTextureHandle[0], // texture
        0, // level
        false, // layered
        0, // layer
        GLES31.GL_READ_ONLY, // access
        GLES31.GL_RGBA8 // format
    ); // 绑定纹理到着色器中
}

GLES31.glTexStorage2D 以及 GLES31.glBindImageTexture 方法中的参数与GLSL中的这句互相对应

uniform layout (rgba8, binding = 0) readonly highp image2D u_inputImage;

int INPUT_TEXTURE_UNIT_INDEX = 0 对应 binding = 0,GLES31.GL_RGBA 对应 rgba8,GLES31.GL_READ_ONLY 对应 readonly

注:glActiveTexture 是在 ES 2.0 版本中处理多重纹理用的,我发现在 ES 3.1 的计算着色器中不加这个也行,计算着色器有 glBindImageTexture 的第一个参数 unit 去与 glsl 的 binding 对应。但是因为官方Demo有,我就先放在这。

绑定输出图片纹理

GLES31.glGenTextures(1, mOutputTextureHandle, 0);
GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, mOutputTextureHandle[0]);
GLES31.glTexStorage2D(
GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA8,
    width, height
);
GLES31.glActiveTexture(GLES31.GL_TEXTURE0 + OUTPUT_TEXTURE_UNIT_INDEX);
if (mOutputTextureHandle[0] != 0) {
    GLES31.glBindImageTexture(
        OUTPUT_TEXTURE_UNIT_INDEX, // OUTPUT_TEXTURE_UNIT_INDEX == 1
        mOutputTextureHandle[0],
        0,
        false,
        0,
        GLES31.GL_WRITE_ONLY,
        GLES31.GL_RGBA8
    );
}

这里跟输入纹理逻辑差不多,只是不需要 glTexSubImage2D 传入数据,这个纹理是用来读数据用的。

绑定缓冲区

GLES31.glGenFramebuffers(1, mOffscreenBufferHandle, 0);
GLES31.glBindFramebuffer(GLES31.GL_FRAMEBUFFER, mOffscreenBufferHandle[0]);
GLES31.glFramebufferTexture2D(
    GLES31.GL_FRAMEBUFFER,
    GLES31.GL_COLOR_ATTACHMENT0,
    GLES31.GL_TEXTURE_2D,
    mOutputTextureHandle[0],
    0
);

关键就是要把输出纹理 mOutputTextureHandle 加到缓冲区中。

然后计算,并等待计算结束

GLES31.glDispatchCompute(
    roundUp(srcWidth, 8), // num_groups_x
    roundUp(srcHeight, 8), // num_groups_y
    1 // num_groups_z
);
GLES31.glMemoryBarrier(GLES31.GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
private int roundUp(int base, int divisor) {
    return (int) Math.ceil((double) base/ divisor);
}

这里的 roundUp 就是计算工作组数量,假设我们有 1000 * 1000 的数据,由于我们设置了 local_size_x = 8, local_size_y = 8,也就是本地工作组的大小是 8 * 8,那么我们需要的工作组就是 1000 / 8,即 125 * 125 个工作组。

更多关于工作组的说明可以看这篇

GPGPU基础(五):使用compute shader进行通用计算及示例_compute shader 计算单元-优快云博客

计算完成后指定缓冲区读回

GLES31.glBindFramebuffer(GLES31.GL_FRAMEBUFFER, mOffscreenBufferHandle[0]);
GLES20.glReadPixels(0, 0, w, h, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, mReadBuf); // IntBuffer
// 把读出的数据转成Bitmap
Bitmap outBmp = BitmapStore.getInstance().getBitmap(w, h, Bitmap.Config.ARGB_8888);
outBmp.copyPixelsFromBuffer(mReadBuf);

最后将 Bitmap 放到 ImageView 中显示,得到与第一步中一样的显示效果。

至此,我们用计算着色器处理图片的工作就完成了。

六、计算着色器-通用计算

接下来,我们再尝试一下用这个计算着色器计算 log2 的值

着色器

#version 310 es
layout (local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

uniform layout (rgba8, binding = 0) readonly highp image2D u_inputImage;
uniform layout (rgba8, binding = 1) writeonly highp image2D u_outputImage;

void main(){
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
    vec4 inputPixel = imageLoad(u_inputImage, texelCoord);

    // 将纹理颜色值 rgba 取出
    // inputPixel.r 得到的值会是 float 0.0 ~ 1.0 间的值,原本是 255,得到的是 1.0,所以下方 * 255 是把原本byte复原回来
    int ir = int(inputPixel.r * 255.0);
    int ig = int(inputPixel.g * 255.0);
    int ib = int(inputPixel.b * 255.0);
    int ia = int(inputPixel.a * 255.0);

    // 将原本的 4 bytes转成一个int
    int ii = (ir & 0xFF) + ((ig & 0xFF) << 8) + ((ib & 0xFF) << 16) + ((ia & 0xFF) << 24);
    // 得到真实传入的 float 值
    float f = intBitsToFloat(ii);
    // 计算以2为底的log值
    f = log(f) / log(2.0);
    // 把计算结果 float 转回 int
    int oi = floatBitsToInt(f);
    // 把输出的rgba转回 0.0~1.0 的 float
    float r = float((oi) & 0xFF) / 255.0;
    float g = float((oi >> 8) & 0xFF) / 255.0;
    float b = float((oi >> 16) & 0xFF) / 255.0;
    float a = float((oi >> 24) & 0xFF) / 255.0;

    imageStore(u_outputImage, texelCoord, vec4(r, g, b, a));
}

java代码跟上一步处理图片的差不多,只是写入纹理数据不是 GLUtils.texSubImage2D ,而是 GLES31.glTexSubImage2D 传入 FloatBuffer

FloatBuffer buffer = FloatBuffer.wrap(srcData);
GLES31.glTexSubImage2D(GLES31.GL_TEXTURE_2D, 0, 0, 0, srcWidth, srcHeight, GLES31.GL_RGBA, GLES31.GL_UNSIGNED_BYTE, buffer);

然后读取计算结果也是读到 FloatBuffer

FloatBuffer buf = FloatBuffer.allocate(1000000);
GLES20.glReadPixels(0, 0, data.width, data.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf);
float[] res = buf.array();

最后的 res 就是我们要的计算结果

兼容性

计算着色器要求 OpenGL ES 3.1 以上才支持,我的 Android 9 模拟器不支持,Android 15 模拟器支持,我试了我手边大部分的真机都支持(原本期待10年前的Android5古董手机的结果,奈何不支持)。

下面这个方法可以取得手机的 OpenGL ES 版本

public static int getGlVersion(Context context) {
    ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
    ConfigurationInfo info = am.getDeviceConfigurationInfo();
    String versionStr = Integer.toHexString(info.reqGlEsVersion);
    return Integer.parseInt(versionStr);
}

此值 >= 30001 的设备都能支持计算着色器。

另外,在部分真机中,首次渲染并读取数据时会读到全是0,第二次读取能正确读出,这是个小坑。

七、计算着色器-直接处理 float32

上面那一步是基于最早利用图片的 rgba8 版本改出来的,实际上计算着色器可以直接支持传入 float32 的,不需要 rgba 跟 float 互转了,所以我们再把上一步的代码再优化一下

着色器

#version 310 es
layout (local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

uniform layout (rgba32f, binding = 0) readonly highp image2D u_inputImage;
uniform layout (rgba32f, binding = 1) writeonly highp image2D u_outputImage;

void main(){
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
    vec4 inputPixel = imageLoad(u_inputImage, texelCoord);

    float l2 = log(2.0);
    float r = log(inputPixel.r) / l2;
    float g = log(inputPixel.g) / l2;
    float b = log(inputPixel.b) / l2;
    float a = log(inputPixel.a) / l2;

    imageStore(u_outputImage, texelCoord, vec4(r, g, b, a));
}

java中,也把原来的 GL_RGBA 改成 GL_RGBA32F

// 绑定输入纹理
GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[0]);
GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
FloatBuffer buffer = FloatBuffer.wrap(srcData);
GLES31.glTexSubImage2D(GLES31.GL_TEXTURE_2D, 0, 0, 0, srcWidth, srcHeight, GLES31.GL_RGBA, GLES31.GL_FLOAT, buffer); // 将数据写入纹理
GLES31.glBindImageTexture(0 /* binding */, fTexture[0], 0, false, 0, GLES31.GL_READ_ONLY /* readonly */, GLES31.GL_RGBA32F /* rgba32f */);

// 绑定输出纹理
GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[1]);
GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
GLES31.glBindImageTexture(1, fTexture[1], 0, false, 0, GLES31.GL_WRITE_ONLY, GLES31.GL_RGBA32F);

另外,目前计算数组的大小是 1000 * 1000,之前的 srcWidth、srcHeight 是 1000。

现在由于传入的纹理格式已经是 rgba32f 了,也就是 rgba 每一个分量都是一个 float,也就是一个像素(工作项)可以处理4个 float,所以此时 srcWidth、srcHeight 改成 500。

最后,读取数据

GLES20.glReadPixels(0, 0, data.widthF32, data.heightF32, GLES20.GL_RGBA, GLES20.GL_FLOAT, buf);

最终的计算结果与上一步的相同,并且运行效率还略高,这就是我目前阶段的最终版

性能对比

计算 float[1000000] 的log2值100次,计算着色器 VS java。

Android 15(API35)模拟器

OpenGL:950 ms

Java:11000 ms

荣耀V20(麒麟980,2018年中高端)

OpenGL:600~700 ms

Java:1700 ms

红米 Note 11T Pro(天玑8100,2022年中高端)

OpenGL:700~900 ms

Java:1135 ms

华为畅享9(骁龙450,2017年中低端)

OpenGL:5131 ms

Java:11616 ms

荣耀V9(麒麟960,2016中高端)

OpenGL:2100 ms

Java:4319 ms

硕王MX670pro(CPU手机里写骁龙8+,2022年中高端,不过非大品牌手机真实性存疑)

OpenGL:1050 ms

Java:3142 ms

从上述结果看,利用 OpenGL 进行通用计算,确实能有效提升运算速度,算法越复杂,效果会越明显。

八、多重纹理输入输出

上面部分已经实现了计算着色器的基本通用计算,之前只有单输入、单输出。实际上,计算着色器可以有多输入,多输出,可以支持更复杂的算法。

下方例子是传入两个数组,再把两数组的元素各自相加、相乘,并把两个结果输出

着色器

#version 310 es
layout (local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

uniform layout (rgba32f, binding = 0) readonly highp image2D u_inputImage1;
uniform layout (rgba32f, binding = 1) readonly highp image2D u_inputImage2;
uniform layout (rgba32f, binding = 2) writeonly highp image2D u_outputImage1;
uniform layout (rgba32f, binding = 3) writeonly highp image2D u_outputImage2;

void main(){
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
    vec4 inputPixel1 = imageLoad(u_inputImage1, texelCoord);
    vec4 inputPixel2 = imageLoad(u_inputImage2, texelCoord);

    // 第一个输出相加
    float r1 = inputPixel1.r + inputPixel2.r;
    float g1 = inputPixel1.g + inputPixel2.g;
    float b1 = inputPixel1.b + inputPixel2.b;
    float a1 = inputPixel1.a + inputPixel2.a;

    // 第二个输出相乘
    float r2 = inputPixel1.r * inputPixel2.r;
    float g2 = inputPixel1.g * inputPixel2.g;
    float b2 = inputPixel1.b * inputPixel2.b;
    float a2 = inputPixel1.a * inputPixel2.a;

    imageStore(u_outputImage1, texelCoord, vec4(r1, g1, b1, a1));
    imageStore(u_outputImage2, texelCoord, vec4(r2, g2, b2, a2));
}

java绑定纹理

/** 缓冲区 */
private int[] fFrame = new int[1];
/** 4个纹理,2个输入2个输出 */
private int[] fTexture = new int[4];

public void onSurfaceCreated() {

    // ...

    GLES31.glGenTextures(fTexture.length, fTexture, 0);
    GLES31.glGenFramebuffers(1, fFrame, 0);
}

private int lastWidth, lastHeight;

private void bindTexture() {
    if(lastWidth != srcWidth || lastHeight != srcHeight) {
        // 绑定输入纹理
        GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[0]);
        GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
        GLES31.glBindImageTexture(0, fTexture[0], 0, false, 0, GLES31.GL_READ_ONLY, GLES31.GL_RGBA32F);
        GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[1]);
        GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
        GLES31.glBindImageTexture(1, fTexture[1], 0, false, 0, GLES31.GL_READ_ONLY, GLES31.GL_RGBA32F);


        // 绑定输出纹理
        GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[2]);
        GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
        GLES31.glBindImageTexture(2, fTexture[2], 0, false, 0, GLES31.GL_WRITE_ONLY, GLES31.GL_RGBA32F);
        GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[3]);
        GLES31.glTexStorage2D(GLES31.GL_TEXTURE_2D, 1, GLES31.GL_RGBA32F, srcWidth, srcHeight);
        GLES31.glBindImageTexture(3, fTexture[3], 0, false, 0, GLES31.GL_WRITE_ONLY, GLES31.GL_RGBA32F);

        // 绑定缓冲区
        GLES31.glBindFramebuffer(GLES31.GL_FRAMEBUFFER, fFrame[0]);
        GLES31.glFramebufferTexture2D(GLES31.GL_FRAMEBUFFER, GLES31.GL_COLOR_ATTACHMENT0,
            GLES31.GL_TEXTURE_2D, fTexture[2], 0); // GL_COLOR_ATTACHMENT0 用来读输出值1
        GLES31.glFramebufferTexture2D(GLES31.GL_FRAMEBUFFER, GLES31.GL_COLOR_ATTACHMENT1,
            GLES31.GL_TEXTURE_2D, fTexture[3], 0); // GL_COLOR_ATTACHMENT1 用来读输出值2
        GLES31.glFramebufferTexture2D(GLES31.GL_FRAMEBUFFER, GLES31.GL_COLOR_ATTACHMENT2,
            GLES31.GL_TEXTURE_2D, fTexture[0], 0); // GL_COLOR_ATTACHMENT2 用来读输入值1
        lastWidth = srcWidth;
        lastHeight = srcHeight;
    }

    GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[0]);
    FloatBuffer buffer1 = FloatBuffer.wrap(srcData1);
    GLES31.glTexSubImage2D(GLES31.GL_TEXTURE_2D, 0, 0, 0, srcWidth, srcHeight, GLES31.GL_RGBA, GLES31.GL_FLOAT, buffer1);
    GLES31.glBindTexture(GLES31.GL_TEXTURE_2D, fTexture[1]);
    FloatBuffer buffer2 = FloatBuffer.wrap(srcData2);
    GLES31.glTexSubImage2D(GLES31.GL_TEXTURE_2D, 0, 0, 0, srcWidth, srcHeight, GLES31.GL_RGBA, GLES31.GL_FLOAT, buffer2);
}

读取计算结果

// 读输出1
FloatBuffer buf1 = FloatBuffer.wrap(data.dest1);
GLES31.glReadBuffer(GLES31.GL_COLOR_ATTACHMENT0);
GLES20.glReadPixels(0, 0, data.width, data.height, GLES20.GL_RGBA, GLES20.GL_FLOAT, buf1);
// 读输出2
FloatBuffer buf2 = FloatBuffer.wrap(data.dest2);
GLES31.glReadBuffer(GLES31.GL_COLOR_ATTACHMENT1);
GLES20.glReadPixels(0, 0, data.width, data.height, GLES20.GL_RGBA, GLES20.GL_FLOAT, buf2);
// 输入值也能按这种方式读
FloatBuffer buf3 = FloatBuffer.allocate(data.src1.length);
GLES31.glReadBuffer(GLES31.GL_COLOR_ATTACHMENT2);
GLES20.glReadPixels(0, 0, data.width, data.height, GLES20.GL_RGBA, GLES20.GL_FLOAT, buf3);

最终计算结果如下

总结

我们已经利用OpenGL的计算着色器实现了通用计算,对比java的计算确实有显著的性能提升。

目前主要的性能瓶颈是读取计算结果的过程,实际在GPU的计算是一瞬间完成的,所以要实际使用时要尽量在一次的计算任务中完成所有的事,减少CPU与GPU的数据交换。可以利用多纹理输入、输出来减少数据读取频率。

GPU中还可以在计算单元内共享数据,以处理更复杂的算法;这一点我目前还没尝试过,这是我下一步要研究的目标。

您可能感兴趣的与本文相关的镜像

Wan2.2-I2V-A14B

Wan2.2-I2V-A14B

图生视频
Wan2.2

Wan2.2是由通义万相开源高效文本到视频生成模型,是有​50亿参数的轻量级视频生成模型,专为快速内容创作优化。支持480P视频生成,具备优秀的时序连贯性和运动推理能力

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值