将脚本迁移到 OpenGL ES 3.1

对于 GPU 计算非常理想的工作负载,将 RenderScript 脚本迁移到 OpenGL ES (GLES) 允许用 Kotlin、Java 或使用 NDK 编写的应用程序利用 GPU 硬件的优势。以下提供高级概述,以帮助您使用 OpenGL ES 3.1 计算着色器替换 RenderScript 脚本。

GLES 初始化

无需创建 RenderScript 上下文对象,请执行以下步骤使用 EGL 创建 GLES 离屏上下文

  1. 获取默认显示

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

  3. 选择一个表面类型为 EGL_PBUFFER_BIT 的 EGL 配置。

  4. 使用显示和配置创建 EGL 上下文。

  5. 使用 eglCreatePbufferSurface 创建离屏表面。如果上下文仅用于计算,则此表面可以是微不足道的(1x1)。

  6. 创建渲染线程并调用 eglMakeCurrent 在渲染线程中使用显示、表面和 EGL 上下文将 GL 上下文绑定到线程。

示例应用演示了如何在 GLSLImageProcessor.kt 中初始化 GLES 上下文。要了解更多信息,请参阅 EGLSurface 和 OpenGL ES

GLES 调试输出

从 OpenGL 获取有用的错误使用扩展启用调试日志记录,该日志记录设置调试输出回调。从 SDK 执行此操作的方法 glDebugMessageCallbackKHR 从未实现,并抛出 异常。该 示例应用 包括来自 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 buffer 对象。

  • Uniform buffer:推荐用于大小超过推送常量限制且经常更改的脚本全局变量。

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

执行计算

计算着色器不是图形管线的一部分;它们是通用目的的,旨在计算高度可并行的作业。这使您可以更好地控制它们的执行方式,但也意味着您需要更多地了解作业的并行化方式。

创建和初始化计算程序

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

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

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

  3. 附加着色器,链接程序,并使用程序。

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

启动计算

计算着色器在一个抽象的 1D、2D 或 3D 空间中的一系列工作组内操作,这些工作组在着色器源代码中定义,并表示最小调用大小以及着色器的几何形状。以下着色器在一个 2D 图像上工作,并在两个维度上定义工作组。

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 的代码),请创建和调度多个程序,并使用内存屏障同步它们对输出的访问。

示例应用 演示了两个计算任务。

  • 色调旋转:具有单个计算着色器的计算任务。有关代码示例,请参阅 GLSLImageProcessor::rotateHue
  • 模糊:一个更复杂的计算任务,它依次执行两个计算着色器。有关代码示例,请参阅 GLSLImageProcessor::blur

要了解有关内存屏障的更多信息,请参阅 确保可见性 以及 共享变量