将脚本迁移到 OpenGL ES 3.1

对于非常适合使用 GPU 计算的工作负载,将 RenderScript 脚本迁移到 OpenGL ES (GLES),有助于使用 Kotlin、Java 或 NDK 编写的应用充分利用 GPU 硬件。下面简要介绍了相关内容,旨在帮助您使用 OpenGL ES 3.1 计算着色器替换 RenderScript 脚本。

GLES 初始化

通过执行以下步骤,使用 EGL 创建 GLES 屏幕外上下文,而不要创建 RenderScript 上下文对象:

  1. 获取默认屏幕

  2. 使用默认屏幕初始化 EGL,指定 GLES 版本。

  3. 选择 surface 类型为 EGL_PBUFFER_BIT 的 EGL 配置。

  4. 使用 display 和 config 创建 EGL 上下文。

  5. 使用 eglCreatePBufferSurface 创建屏幕外 surface。如果上下文将仅用于计算,则 surface 可能会非常小 (1x1)。

  6. 创建渲染线程,然后在渲染线程中使用 display、surface 和 EGL 上下文调用 eglMakeCurrent,以便将 GL 上下文绑定到该线程。

示例应用演示了如何在 GLSLImageProcessor.kt 中初始化 GLES 上下文。如需了解详情,请参阅 EGLSurfaces 和 OpenGL ES

GLES 调试输出

为了从 OpenGL 获取有用的错误信息,需要使用扩展程序来启用可设置调试输出回调的调试日志记录。通过 glDebugMessageCallbackKHR 方法从 SDK 中执行此操作的做法尚未实现,并且会抛出异常示例应用包含 NDK 代码中回调的封装容器。

GLES 分配

RenderScript 分配可以迁移至不可变存储纹理着色器存储缓冲区对象。对于只读图片,您可以使用允许进行过滤的采样器对象

GLES 资源在 GLES 中分配。为避免在与其他 Android 组件交互时产生内存复制开销,可使用KHR 图片的扩展程序来共享二维图片数据数组。从 Android 8.0 开始,Android 设备必须支持此扩展程序。graphics-core Android Jetpack 库支持在受管理代码中创建这些图片并将它们映射到分配的 HardwareBuffer

val outputBuffers = Array(numberOfOutputImages) {
  HardwareBuffer.create(
    width, height, HardwareBuffer.RGBA_8888, 1,
    HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
  )
}
val outputEGLImages = Array(numberOfOutputImages) { i ->
    androidx.opengl.EGLExt.eglCreateImageFromHardwareBuffer(
        display,
        outputBuffers[i]
    )!!
}

遗憾的是,这不会创建计算着色器直接写入缓冲区所需的不可变存储纹理。该示例使用 glCopyTexSubImage2D 将计算着色器使用的存储纹理复制到 KHR Image 中。如果 OpenGL 驱动程序支持 EGL 图片存储扩展程序,则可以使用该扩展程序来创建共享的不可变存储纹理,以避免复制。

转换为 GLSL 计算着色器

您的 RenderScript 脚本会转换为 GLSL 计算着色器。

编写 GLSL 计算着色器

在 OpenGL ES 中,计算着色器使用 OpenGL 着色语言 (GLSL) 编写。

调整脚本全局变量

根据脚本全局变量的特性,您可以对未在着色器中修改的全局变量使用 uniform 或 uniform 缓冲区对象:

  • uniform 缓冲区:建议用于频繁更改且大于推送常量上限的脚本全局变量。

对于着色器中发生变化的全局变量,您可以使用不可变存储纹理着色器存储缓冲区对象

执行计算

计算着色器不是图形流水线的一部分;它们是通用的,并且可用于计算高度可并行的作业。这有助您更好地控制作业的执行方式,但也意味着您必须更详细地了解作业的并行处理方式。

创建并初始化计算程序

创建和初始化计算程序与使用任何其他 GLES 着色器有很多共同之处。

  1. 创建程序以及与其关联的计算着色器。

  2. 附加着色器源代码,编译着色器(并检查编译结果)。

  3. 连接着色器,关联程序,然后使用该程序。

  4. 创建、初始化和绑定任何 uniform。

启动计算

计算着色器在着色器源代码中定义的一系列工作组的一维、二维或三维抽象空间内运行,表示最小调用大小以及着色器的几何图形。以下着色器适用于二维图片,并在两个维度上定义了工作组:

private const val WORKGROUP_SIZE_X = 8
private const val WORKGROUP_SIZE_Y = 8
private const val ROTATION_MATRIX_SHADER =
    """#version 310 es
    layout (local_size_x = $WORKGROUP_SIZE_X, local_size_y = $WORKGROUP_SIZE_Y, local_size_z = 1) in;

工作组可以共享内存(由 GL_MAX_COMPUTE_SHARED_MEMORY_SIZE 定义),该内存至少为 32 KB,并且可以利用 memoryBarrierShared() 提供一致的内存访问。

定义工作组大小

即使问题空间适合大小为 1 的工作组,设置适当的工作组大小对于并行处理计算着色器也非常重要。例如,如果工作组大小过小,GPU 驱动程序可能无法充分并行执行计算。理想情况下,这些大小应按 GPU 进行调整,但合理的默认值会在当前设备上充分并行执行计算,例如着色器代码段中的工作组大小为 8x8 时。

其中提供了一个 GL_MAX_COMPUTE_WORK_GROUP_COUNT,但它的值很大;根据相关规范,三个轴都必须至少为 65535。

调度着色器

执行计算的最后一步是使用其中一个调度函数(例如 glDispatchCompute)调度着色器。调度函数负责设置每个轴的工作组数量:

GLES31.glDispatchCompute(
  roundUp(inputImage.width, WORKGROUP_SIZE_X),
  roundUp(inputImage.height, WORKGROUP_SIZE_Y),
  1 // Z workgroup size. 1 == only one z level, which indicates a 2D kernel
)

如需返回值,请先使用内存屏障等待计算操作完成:

GLES31.glMemoryBarrier(GLES31.GL_SHADER_IMAGE_ACCESS_BARRIER_BIT)

如需将多个内核链接在一起(例如,如需使用 ScriptGroup 迁移代码),请创建并调度多个程序,并将它们对输出的访问与内存屏障进行同步。

示例应用演示了两项计算任务:

  • HUE 旋转:一个具有单个计算着色器的计算任务。如需查看代码示例,请参阅 GLSLImageProcessor::rotateHue
  • 模糊处理:一个按顺序执行两个计算着色器的更复杂的计算任务。如需查看代码示例,请参阅 GLSLImageProcessor::blur

如需详细了解内存屏障,请参阅确保可见性以及共享变量