Compose 跨平台上面的 AGSL Shader

借助 AGSL Shader 突破 Compose 跨平台的边界

借助Compose和 Shader 在跨平台上打造惊艳图形

Compose 跨平台技术近年来备受关注, 尤其是在去年 Google I/O 大会上正式宣布支持后. 然而, 在这个不断发展的领域中, 持续探索新挑战至关重要. 这促使我研究在该场景下使用 Shader 的可行性. 本文将演示如何使用 AGSL 创建 Shader , 并实现其在不同平台上的无缝运行.

为了说明这一点, 我开发了一个简单应用, 其中 Shader 扮演核心角色. 该项目名为 Photo-FX, 可在 GitHub 上获取(同时提供基于网页的实时版本 在这里). 该应用允许用户选择一张照片并应用多种效果, 同时可调整所选效果的具体参数:

YouTube

Shader 的解剖结构 🧩

无需担心------这并非关于Shonda Rhimes获奖电视剧的讨论. 相反, 我们将深入探索 Shader 的精彩世界及其使用方法. 系好安全带!

简单来说, Shader 是可插入图形管道不同阶段的代码片段, 由 GPU 执行. 根据插入位置的不同, 它们的名称和功能可能会略有差异. Shader 有多种类型, 包括片段 Shader , 顶点 Shader , 几何 Shader 和细分 Shader 等.

我们将重点关注第一种类型: 片段 Shader (也称为像素 Shader ). 这些 Shader 特别重要, 因为从 Android 13 开始, 我们可以轻松地与它们进行交互并将其集成到 Compose 中.

片段 Shader

要理解片段 Shader , 首先需要了解它们运行的上下文. 片段 Shader 是 GPU 程序, 会在屏幕缓冲区中的每个像素上并行运行. 其主要功能是计算每个像素的最终颜色.

你可以将片段 Shader 视为一个具有两个关键组件的函数:

  • 输入: 正在处理的像素的坐标.
  • 输出: 为该特定像素确定的颜色.

这个过程会同时对屏幕缓冲区中的所有像素进行. 下图展示了屏幕缓冲区中几个像素的这个过程:

Github-asset

编程语言: AGSL

要编写 Shader , 我们必须使用一种称为着色语言的领域特定编程语言. 着色语言与传统编程语言在几个关键方面有所不同:

  • 并行执行: 如前所述, 着色语言专为在GPU上运行而设计, 支持多线程并行执行.
  • 数据类型与精度: 着色语言包含专用数据类型(如向量和矩阵), 并支持不同精度Modifier 以优化性能和内存使用.
  • 输入与输出: Shader 通常从CPU接收输入, 并将输出传递给管道的下一阶段或帧缓冲区, 依赖于图形API进行通信.
  • 缺乏通用编程的标准库: 着色语言缺乏用于文件输入输出, 网络或线程等通用任务的标准库. 相反, 它们专注于数学和图形操作.

我们在 Android 中使用的着色语言是 AGSL(Android 图形着色语言), 它本质上与 Skia 库定义的 SKSL 着色语言相同. Skia 是 Android 和 Compose Multiplatform 中所有低级图形处理的基石.

AGSL 在某些方面与 Khronos 集团定义的 GLSL 着色语言存在差异------Khronos 集团是一个行业联盟, 致力于制定图形, 计算和媒体的开放标准. Khronos Group 负责 OpenGL 规范的开发和维护, 其中包括 GLSL. 尽管这两种语言在语法, 语句, 某些类型和内置函数等方面有许多相似之处, 但 AGSL 和 GLSL 之间最显著的差异在于它们的坐标系统. 在 GLSL 中, 原点通常位于屏幕的左下角, 而在 AGSL 中, 原点位于屏幕的左上角.

让我们探索一个简单的片段 Shader , 它将屏幕缓冲区填充为红色:

javascript 复制代码
half4 main(vec2 fragCoord) { 
    return half4(1.0, 0.0, 0.0, 1.0);
}

如你所见, AGSL是一种类似C的语言, 每个语句末尾都以分号结尾.

在第1行, 我们定义了主函数, 这是进入 Shader 的入口点. 该函数有一个输入参数, 表示像素坐标. 我们将其命名为fragCoord, 但你可以使用任何适合你的名称.

在第 2 行, 我们创建了一个 half4 类型的值, 该类型表示四个 16 位浮点数. 此值存储我们正在绘制的像素的颜色------在本例中为红色.

下图显示了此 Shader 生成的效果:

就这样------我们的第一个 Shader ! 这只是我们踏入 Shader 世界之旅的开端!

uniform

为了让效果更丰富, 我们可以提供额外数据来影响生成的视觉效果. 这些数据将在构成单个屏幕缓冲区的所有并行 Shader 执行中共享. 要定义此共享数据, 我们使用 uniform 关键字放在数据类型之前. 我们可以发送给 Shader 的数据类型有限: 整数, 浮点数和 Shader . 例如, 我们可以发送屏幕缓冲区尺寸以沿 x 轴创建线性渐变, 如下面的代码片段所示:

ini 复制代码
uniform float2 resolution;
uniform float4 colour;
half4 main(vec2 fragCoord) {
    return half4(colour.rgb * fragCoord.x / resolution.x, 1.);
}

在第 1 行, 我们定义了一个名为 resolutionuniform 变量, 类型为 float2, 表示包含两个浮点数的向量. 在第 2 行, 我们定义了另一个名为 colouruniform 变量, 表示用于绘制渐变的颜色 RGBA 值.

第 5 行是关键部分. 与我们之前讨论的第一个 Shader 类似, 我们返回当前调用的颜色. 在此情况下, 我们计算 fragCoord.xresolution.x 的比值, 将当前 x 坐标值映射到 0.0 到 1.0 的范围. 该值随后与输入颜色 colour 相乘, 生成新的颜色值, 从而绘制出从黑色到输入颜色的水平渐变, 如下方实时示例所示: Github-asset 已添加一个滑块, 允许你修改输入颜色值. 欢迎尝试!

Compose Multiplatform 内部 ⚒️

在深入探讨为 Kotlin 跨平台项目添加 Shader 的可用 API 之前, 让我们先看看使 Compose 跨平台能够无缝运行的基本构建块. 请考虑以下图表:

该图展示了 Compose Multiplatform 的简化结构, 特别针对 Shader 场景进行优化. 如图所示, 当目标平台为 Android 时, 我们依赖 Android SDK 提供 Shader 支持. 对于其他平台, 我们依赖 Skia------由 Google 主要开发和维护的开源 2D 图形库. Skia 通过调用不同平台的原生库实现其功能:

  • iOS: Metal 和 CoreGraphics.
  • Windows: Direct3D 和 GDI.
  • macOS: OpenGL, Metal 和 CoreGraphics.
  • Linux: OpenGL, X11 和 Wayland.
  • Web: WebAssembly 和 Canvas API.

这一基础架构使 Compose Multiplatform 能够在不同环境中高效渲染图形, 使其成为创建视觉丰富应用程序的强大工具.

将 Shader 集成到 Composable 中

回到 Compose 层, 我们有一种简单的方法将 Shader 集成到 Composable 中. 一个特别强大的工具是 graphicsLayer Modifier , 它允许我们在渲染管道中直接对 Composable 组件应用各种图形变换和效果. 我们感兴趣的这个Modifier 的版本需要提供一个具有以下签名的 lambda 函数:

kotlin 复制代码
fun Modifier.graphicsLayer(block: GraphicsLayerScope.() -> Unit): Modifier

在此 lambda 中, 我们可以调整 GraphicsLayerScope 接收器的任何属性. 在允许修改缩放, 旋转和平移的属性中, 有一个名为 renderEffect 的属性------这就是将 Shader 集成到 Compose 中的关键元素!

有用的 API

在了解 Compose Multiplatform 的结构以及创建 Shader 所需的内容后, 我们可以探索用于 Shader 创建的可用 API. 如前文图表所示, 为 Android 创建 Shader 需要使用 Android SDK 的方法, 而其他平台则依赖 Skia.

以下列表总结了这些 Android SDK API, 供你参考:

  • RuntimeShader() : 从输入的 AGSL 源代码创建新的 RuntimeShader.
  • RenderEffect.createShaderEffect()/RenderEffect.createRuntimeShaderEffect() : 从输入参数创建新的 RenderEffect.
  • RenderEffect.asComposeRenderEffect() : 创建一个与Compose兼容的RenderEffect.

以下是Skia API的列表:

  • RuntimeEffect.makeForShader() : 根据输入的AGSL源代码创建一个新的RuntimeEffect.
  • RuntimeShaderBuilder() : 根据输入的 RuntimeEffect 创建一个新的 RuntimeShaderBuilder.
  • ImageFilter.makeShader()/ImageFilter.makeRuntimeShader() 从输入参数创建新的 ImageFilter.
  • ImageFilter.asComposeRenderEffect() : 从 ImageFilter 创建一个与 Compose 兼容的 RenderEffect.

这些 API 方法将使我们能够使用每个目标平台的适当工具创建 Shader . 下图说明了如何链式调用这些方法, 以在 graphicsLayer Modifier 中正确提供所需的 renderEffect:

你可能已经注意到, 在创建 RuntimeEffectRuntimeShader 之后, 两种情况下都始终有两个方法可供下一步使用. 在每种情况下, 第二个方法都会创建一个 RenderEffectImageFilter, 用于处理底层 Composable 的内容. 这使我们能够以任何创意方式使用 Shader 中的图形数据------这是 Photo-FX 项目中至关重要的一环, 我们稍后将详细探讨.

那么关于uniform(uniforms)呢?如何为它们指定值?这同样取决于平台:

  • Android SDK : RuntimeShader 提供了如 setIntUniformsetFloatUniform 等方法, 这些方法接受一个字符串(代表 AGSL 代码中的名称)和对应的值.
  • Skia : uniform通过 RuntimeShaderBuilderuniform 方法指定. 其签名与 Android SDK 类似: 一个字符串加上一个类型化的值.

在 Compose 中实践 Shader ✍️

现在所有组件就位, 我们可以创建 Shader 并将其应用于任何我们选择的 Composable . 例如, 以下基于 Android 的代码片段演示了如何创建一个将可组合内容染成红色 Shader :

less 复制代码
@Language("AGSL")
private val TINT_SHADER = """
    uniform shader content;
    uniform float4 tint;
    half4 main(vec2 fragCoord) {
        return mix(content.eval(fragCoord), half4(tint), 0.5);
    }

让我们分解这个简单的 Shader 代码. 在第 1 行, 存储 AGSL 代码的变量使用 @Language 注解, 这有助于 IDE 正确解释并突出显示该字符串的内容为 AGSL.

在第 3 行和第 4 行, 我们声明了 Shader 所需的uniform. 最后, 在第 6 行, 核心功能被实现为一行代码. 通过调用 mix 方法, 我们将两种颜色进行混合: 来自内容 Shader 的颜色和输入的 tint 值. 这就是我们 Shader 的全部内容.

现在, 让我们关注 SimpleShader Composable中的胶水代码. 我们调用 createRuntimeShaderEffect 方法, 该方法需要一个额外参数 uniformShaderName. 这对应于 AGSL 代码中声明的 uniform, 它将接收一个表示Composable内容的 Shader ------在我们的案例中, 是包含"Hello World!"文本的 Box.

你可能同意这段代码并不优雅. 几行代码中创建了多个实例, 所有实例都需要正确组合. 重复此过程多次很容易导致错误. 此外, 这段代码特定于 Android SDK, 这限制了其在跨平台环境中的使用. 因此, 让我们探索一种更好的方法来处理 Shader .

设计跨平台 Shader API ✨

理想的 API 应该具备哪些特征?以下是关键点:

  • 它应提供一个统一的入口点, 用于将 Shader 应用于我们的 Composable .
  • 它应提供一个强大的机制, 用于设置 Shader uniform的值.
  • 它应能够无缝集成到跨平台项目中.

幸运的是, 我们有专门的机制来满足这些要求:

  • 我们可以实现一个新的 Compose Modifier , 作为 Shader 的入口点. 该 Modifier 可以接受 AGSL 代码作为字符串, 并在内部处理所有必要的 API 调用.
  • 此外, 新的 Modifier 可以包含一个作用域 lambda, 提供专门用于设置 uniform 值的方法.
  • 最后, 使跨平台环境中顺畅运行的机制: expect/actual.
展示代码

首先, 让我们探索我们将要实现的 Modifier :

kotlin 复制代码
expect fun Modifier.shader(
    shader: String,
    uniformsBlock: (ShaderUniformProvider.() -> Unit)? = null,
): Modifier

expect fun Modifier.runtimeShader(
    shader: String,
    uniformName: String = "content",
    uniformsBlock: (ShaderUniformProvider.() -> Unit)? = null,
): Modifier

还记得我们在 Android SDK 和 Skia 中有两种不同的方法来创建 Shader 吗?这就是为什么这里也有两个 Modifier . 在两种情况下, AGSL 源代码都作为第一个参数传递. 此外, 一个可选的lambda表达式允许我们通过调用ShaderUniformProvider接口暴露的方法来设置uniform的值, 我们稍后会详细讨论. runtimeShader Modifier 还包含一个额外的字符串参数, 用于指定将接收 Composable 帧缓冲区的 Shader 名称.

这些 Modifier 使用 expect 关键字声明, 这意味着实际实现是平台特定的. 如果你第一次遇到 expect/actual 机制, 我建议查看官方文档.

  • "难道没有人会为uniform着想吗?"
  • 如果你是《辛普森一家》的粉丝, 你会认出这是对海伦·洛维乔伊著名呼吁的变体,
  • "难道没有人会为孩子们着想吗?"
  • 就像海伦一样, 我在此倡导------这次是为了uniform! 这就是为什么我决定提供一个接口来设置它们的值:
kotlin 复制代码
interface ShaderUniformProvider {
    fun uniform(name: String, value: Int)
    fun uniform(name: String, value: Float)
    fun uniform(name: String, value1: Float, value2: Float)
}

你可能还需要额外的方法来传递颜色或其他值类型, 但对于Photo-FX来说, 这些已经足够了.

Skia实现

我们已经看过一些在 Android 中创建 Shader 的代码, 因此将这些代码改编为使用 Skia 实现我们的新Modifier 应该不会太困难. 以下是 Skia API 的使用方式:

kotlin 复制代码
actual fun Modifier.shader(
    shader: String,
    uniformsBlock: (ShaderUniformProvider.() -> Unit)?,
): Modifier = this then composed {
    val runtimeShaderBuilder = remember {
        RuntimeShaderBuilder(
            effect = RuntimeEffect.makeForShader(shader),
        )
    }
    val shaderUniformProvider = remember { 
        ShaderUniformProviderImpl(runtimeShaderBuilder) 
    }
    graphicsLayer {
        clip = true
        renderEffect = ImageFilter.makeShader(
            shader = runtimeShaderBuilder.apply {
                uniformsBlock?.invoke(shaderUniformProvider)
            }.makeShader(),
            crop = null,
        ).asComposeRenderEffect()
    }
}

actual fun Modifier.runtimeShader(
    shader: String,
    uniformName: String,
    uniformsBlock: (ShaderUniformProvider.() -> Unit)?,
): Modifier = this then composed {
    val runtimeShaderBuilder = remember {
        RuntimeShaderBuilder(
            effect = RuntimeEffect.makeForShader(shader),
        )
    }
    val shaderUniformProvider = remember {
        ShaderUniformProviderImpl(runtimeShaderBuilder) 
    }
    graphicsLayer {
        clip = true
        renderEffect = ImageFilter.makeRuntimeShader(
            runtimeShaderBuilder = runtimeShaderBuilder.apply {
                uniformsBlock?.invoke(shaderUniformProvider)
            },
            shaderName = uniformName,
            input = null,
        ).asComposeRenderEffect()
    }
}

private class ShaderUniformProviderImpl(
    private val runtimeShaderBuilder: RuntimeShaderBuilder,
) : ShaderUniformProvider {

    override fun uniform(name: String, value: Int) {
        runtimeShaderBuilder.uniform(name, value)
    }

    override fun uniform(name: String, value: Float) {
        runtimeShaderBuilder.uniform(name, value)
    }

    override fun uniform(name: String, value1: Float, value2: Float) {
        runtimeShaderBuilder.uniform(name, value1, value2)
    }

}

让我们分解最终的实现.

新的 Modifier 将传入的 Modifier 与一个 composed Modifier 结合. 这种方法允许我们高效地保存 RuntimeShaderBuilderShaderUniformProvider 实例, 这些实例可能是昂贵的对象, 我们应避免在每次重新组合时重新创建它们.

此外, 由于可跳过性在 Compose 中至关重要, 通过仅提供 AGSL 源代码作为字符串和一个用于设置 uniform 值的 lambda, 这两个参数均可被视为不可变. 这意味着 Modifier 本身不会在非必要情况下触发重新组合. 唯一可能被重新调用的部分是 graphicsLayer Modifier , 这是有意为之------它允许我们更新 Shader 使用的 uniform 值, 从而实现Modifier 外部的交互.

graphicsLayer 中, 我们还将 clip 属性设置为 true, 以确保绘制不会超出应用该 Shader 的 Composable 的边界.

为 Photo-FX 创建 Shader

现在我们有了新的Modifier 来处理 Shader , 是时候将它们应用到实际场景中了. Photo-FX 的目标是使用 Shader 为图像添加一些视觉上吸引人的效果. 为了让这篇入门文章保持简单, 我们决定只实现三个效果:

这三个效果均涉及基本的像素操作技术, 如加深或调整RGB通道. 我们将重点探讨晕影效果的细节, 其他效果则留给对 Photo-FX 源代码感兴趣的读者自行探索.

晕影效果

晕影效果通过加深图像边缘的亮度, 将视线引导至画面中心. 它在中心向外形成一个微妙的渐变, 随着边缘方向的移动, 阴影强度逐渐增强.

该效果可通过以下代码使用 Shader 轻松实现:

ini 复制代码
uniform float2 resolution;
uniform shader content; 
uniform float intensity;
uniform float decayFactor;

half4 main(vec2 fragCoord) {
    vec2 uv = fragCoord.xy / resolution.xy;
    half4 color = content.eval(fragCoord);
    uv *=  1.0 - uv.yx;
    float vig = clamp(uv.x*uv.y * intensity, 0., 1.);
    vig = pow(vig, decayFactor);
    return half4(vig * color.rgb, color.a);
}

在此 Shader 中, 我们定义了四个uniform:

  • resolution: 表示正在处理的内容的分辨率.
  • content: 这是应用 Shader 的内容或图像.
  • intensity: 控制晕影效果的强度. 较高的强度会使晕影效果更加明显.
  • decayFactor: 控制晕影效果向屏幕边缘衰减的速度. 较高的值会导致更陡峭的衰减.

现在让我们进入 Shader 的主体部分:

  • 第7行 : 我们根据resolution值将坐标归一化到[0,1]范围内.
  • 第8行 : 在此处, 我们根据当前片段坐标评估content Shader , 从内容中获取像素的颜色. 结果存储在color变量中.
  • 第9行 : 在此行中, 我们本质上扭曲了uv坐标, 这些坐标随后将用于计算晕影效果.
  • 第10行 : 晕影因子vig通过将uvxy分量相乘并乘以intensityuniform来计算. clamp函数确保结果值保持在[0, 1]范围内. 该因子决定了颜色随距离中心增加而变暗的程度.
  • 第11行 : 本行通过将晕影因子vig提升至decayFactor次方来调整其值, 该参数控制从中心向屏幕边缘移动时晕影效果的衰减速率. 较高的decayFactor值将使晕影效果衰减得更为陡峭.
  • 第12行 : 最后, 我们返回修改后的颜色. 原始颜色的RGB分量与晕影因子(vig)相乘, 根据像素相对于中心的位置来加深颜色. Alpha分量(color.a)保持不变.

接下来, 我们通过定义一个新的 Modifier (使用runtimeShader Modifier )将此 Shader 集成到Photo-FX应用中, 如下所示:

kotlin 复制代码
fun Modifier.vignetteShader(
    intensity: Float,
    decayFactor: Float,
) = this then runtimeShader(shader) {
    uniform("intensity", intensity)
    uniform("decayFactor", decayFactor)
}

大功告成!现在我们可以在Photo-FX中享受新的 Shader 效果:

YouTube

总结一下

虽然初次接触 Shader 可能令人望而生畏, 但希望本文分享的基础知识能让这一过程变得更加轻松. 在Compose Multiplatform中使用 Shader 尤为令人兴奋. 拥抱新技术和新范式是推动我们作为开发者不断进步的动力, 而学习 Shader 的工作原理------甚至编写自己的 Shader ------是对技能的宝贵投资.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!

相关推荐
aningxiaoxixi1 小时前
android stdio 的布局属性
android
CYRUS STUDIO2 小时前
FART 自动化脱壳框架一些 bug 修复记录
android·bug·逆向·fart·脱壳
寻找优秀的自己3 小时前
Cocos 打包 APK 兼容环境表(Android API Level 10~15)
android·cocos2d
大胃粥4 小时前
WMS& SF& IMS: 焦点窗口更新框架
android
QING6184 小时前
Gradle 核心配置属性详解 - 新手指南(二)
android·前端·gradle
QING6184 小时前
Gradle 核心配置属性详解 - 新手指南(一)
android·前端·gradle
_一条咸鱼_7 小时前
Android Runtime内存管理子系统启动流程原理(13)
android·面试·android jetpack
法迪7 小时前
Android的uid~package~pid的关系
android
二流小码农8 小时前
鸿蒙开发:hvigorw,一个你不得不去了解的神器
android·ios·harmonyos