借助 AGSL Shader 突破 Compose 跨平台的边界
借助Compose和 Shader 在跨平台上打造惊艳图形
Compose 跨平台技术近年来备受关注, 尤其是在去年 Google I/O 大会上正式宣布支持后. 然而, 在这个不断发展的领域中, 持续探索新挑战至关重要. 这促使我研究在该场景下使用 Shader 的可行性. 本文将演示如何使用 AGSL 创建 Shader , 并实现其在不同平台上的无缝运行.
为了说明这一点, 我开发了一个简单应用, 其中 Shader 扮演核心角色. 该项目名为 Photo-FX, 可在 GitHub 上获取(同时提供基于网页的实时版本 在这里). 该应用允许用户选择一张照片并应用多种效果, 同时可调整所选效果的具体参数:
Shader 的解剖结构 🧩
无需担心——这并非关于Shonda Rhimes获奖电视剧的讨论. 相反, 我们将深入探索 Shader 的精彩世界及其使用方法. 系好安全带!
简单来说, Shader 是可插入图形管道不同阶段的代码片段, 由 GPU 执行. 根据插入位置的不同, 它们的名称和功能可能会略有差异. Shader 有多种类型, 包括片段 Shader , 顶点 Shader , 几何 Shader 和细分 Shader 等.
我们将重点关注第一种类型: 片段 Shader (也称为像素 Shader ). 这些 Shader 特别重要, 因为从 Android 13 开始, 我们可以轻松地与它们进行交互并将其集成到 Compose 中.
片段 Shader
要理解片段 Shader , 首先需要了解它们运行的上下文. 片段 Shader 是 GPU 程序, 会在屏幕缓冲区中的每个像素上并行运行. 其主要功能是计算每个像素的最终颜色.
你可以将片段 Shader 视为一个具有两个关键组件的函数:
- 输入: 正在处理的像素的坐标.
- 输出: 为该特定像素确定的颜色.
这个过程会同时对屏幕缓冲区中的所有像素进行. 下图展示了屏幕缓冲区中几个像素的这个过程:
编程语言: 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 , 它将屏幕缓冲区填充为红色:
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 轴创建线性渐变, 如下面的代码片段所示:
uniform float2 resolution;
uniform float4 colour;
half4 main(vec2 fragCoord) {
return half4(colour.rgb * fragCoord.x / resolution.x, 1.);
}
在第 1 行, 我们定义了一个名为 resolution 的 uniform 变量, 类型为 float2, 表示包含两个浮点数的向量. 在第 2 行, 我们定义了另一个名为 colour 的 uniform 变量, 表示用于绘制渐变的颜色 RGBA 值.
第 5 行是关键部分. 与我们之前讨论的第一个 Shader 类似, 我们返回当前调用的颜色. 在此情况下, 我们计算 fragCoord.x 与 resolution.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 函数:
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:
你可能已经注意到, 在创建 RuntimeEffect 或 RuntimeShader 之后, 两种情况下都始终有两个方法可供下一步使用. 在每种情况下, 第二个方法都会创建一个 RenderEffect 或 ImageFilter, 用于处理底层 Composable 的内容. 这使我们能够以任何创意方式使用 Shader 中的图形数据——这是 Photo-FX 项目中至关重要的一环, 我们稍后将详细探讨.
那么关于uniform(uniforms)呢?如何为它们指定值?这同样取决于平台:
- Android SDK:
RuntimeShader提供了如setIntUniform和setFloatUniform等方法, 这些方法接受一个字符串(代表 AGSL 代码中的名称)和对应的值. - Skia: uniform通过
RuntimeShaderBuilder的uniform方法指定. 其签名与 Android SDK 类似: 一个字符串加上一个类型化的值.
在 Compose 中实践 Shader ✍️
现在所有组件就位, 我们可以创建 Shader 并将其应用于任何我们选择的 Composable . 例如, 以下基于 Android 的代码片段演示了如何创建一个将可组合内容染成红色 Shader :
@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 :
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! 这就是为什么我决定提供一个接口来设置它们的值:
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 的使用方式:
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 结合. 这种方法允许我们高效地保存 RuntimeShaderBuilder 和 ShaderUniformProvider 实例, 这些实例可能是昂贵的对象, 我们应避免在每次重新组合时重新创建它们.
此外, 由于可跳过性在 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 轻松实现:
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行: 在此处, 我们根据当前片段坐标评估
contentShader , 从内容中获取像素的颜色. 结果存储在color变量中. - 第9行: 在此行中, 我们本质上扭曲了
uv坐标, 这些坐标随后将用于计算晕影效果. - 第10行: 晕影因子
vig通过将uv的x和y分量相乘并乘以intensityuniform来计算.clamp函数确保结果值保持在[0, 1]范围内. 该因子决定了颜色随距离中心增加而变暗的程度. - 第11行: 本行通过将晕影因子
vig提升至decayFactor次方来调整其值, 该参数控制从中心向屏幕边缘移动时晕影效果的衰减速率. 较高的decayFactor值将使晕影效果衰减得更为陡峭. - 第12行: 最后, 我们返回修改后的颜色. 原始颜色的RGB分量与晕影因子(
vig)相乘, 根据像素相对于中心的位置来加深颜色. Alpha分量(color.a)保持不变.
接下来, 我们通过定义一个新的 Modifier (使用runtimeShader Modifier )将此 Shader 集成到Photo-FX应用中, 如下所示:
fun Modifier.vignetteShader(
intensity: Float,
decayFactor: Float,
) = this then runtimeShader(shader) {
uniform("intensity", intensity)
uniform("decayFactor", decayFactor)
}
大功告成!现在我们可以在Photo-FX中享受新的 Shader 效果:
总结一下
虽然初次接触 Shader 可能令人望而生畏, 但希望本文分享的基础知识能让这一过程变得更加轻松. 在Compose Multiplatform中使用 Shader 尤为令人兴奋. 拥抱新技术和新范式是推动我们作为开发者不断进步的动力, 而学习 Shader 的工作原理——甚至编写自己的 Shader ——是对技能的宝贵投资.
好吧, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy Coding! Stay GOLDEN!