将脚本迁移到 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. 选择 surface 类型为 EGL_PBUFFER_BIT 的 EGL 配置。

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

  5. 使用 eglCreatePBufferSurface 创建离屏 surface。如果该上下文仅用于计算,则这可以是一个微不足道的小 (1x1) surface。

  6. 创建渲染线程并在渲染线程中调用 eglMakeCurrent,同时指定显示器、surface 和 EGL 上下文,将 GL 上下文绑定到该线程。

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

GLES 调试输出

从 OpenGL 获取有用错误的方法是使用扩展来启用调试日志记录,该扩展会设置调试输出回调。从 SDK 调用 glDebugMessageCallbackKHR 的方法从未实现,并且会抛出 异常示例应用包含一个来自 NDK 代码的回调包装器。

GLES 分配

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

GLES 资源在 GLES 内部分配。为避免在与其他 Android 组件交互时产生内存复制开销,存在一个 KHR Images 扩展,该扩展允许共享 2D 图像数据数组。从 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 Image Storage 扩展,则可以使用该扩展创建共享的不可变存储纹理以避免复制。

转换为 GLSL 计算着色器

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

编写 GLSL 计算着色器

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

脚本全局变量的适配

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

  • Uniform 缓冲区:建议用于大小超过推常量限制且频繁更改的脚本全局变量。

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

执行计算

计算着色器不是图形管线的一部分;它们是通用目的的,设计用于计算高度可并行化的任务。这让您可以更好地控制它们的执行方式,但也意味着您必须更多地了解您的任务是如何并行化的。

创建和初始化计算程序

创建和初始化计算程序与处理任何其他 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
)

要返回值,首先使用 memorybarrier 等待计算操作完成

GLES31.glMemoryBarrier(GLES31.GL_SHADER_IMAGE_ACCESS_BARRIER_BIT)

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

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

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

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