
借助 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 , 它将屏幕缓冲区填充为红色:
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 行, 我们定义了一个名为 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 函数:
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
:

你可能已经注意到, 在创建 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 :
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 结合. 这种方法允许我们高效地保存 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 轻松实现:
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
通过将uv
的x
和y
分量相乘并乘以intensity
uniform来计算.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 效果:
总结一下
虽然初次接触 Shader 可能令人望而生畏, 但希望本文分享的基础知识能让这一过程变得更加轻松. 在Compose Multiplatform中使用 Shader 尤为令人兴奋. 拥抱新技术和新范式是推动我们作为开发者不断进步的动力, 而学习 Shader 的工作原理------甚至编写自己的 Shader ------是对技能的宝贵投资.
好吧, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy Coding! Stay GOLDEN!