深入浅出着色器:极坐标系与炫酷环形进度条

本文译自「Circle bars with AGSL」,原文链接medium.com/@off.mind.b...,由Alex Volkov发布于2025年1月6日。

大家好!今天,我将向大家讲解如何使用极坐标系。这是一个重要但又相当简单的主题,因此我选择了一个直截了当的效果,以避免过多地深入讲解其他细节。与往常一样,本教程分为几个部分。首先,我将概述设置着色器所需的最简 Compose 代码。在第二部分中,我将详细解释着色器本身以及使用极坐标的原理。最后,我们将进行一些收尾工作,使效果更加惊艳。

最终,我们将实现如下效果:

在深入探讨之前,我想提醒你,我并没有为每个效果创建教程。不过,所有效果都可以在我的 GitHub 上找到。你还可以在我的 Telegram 频道 中找到视频、新效果的公告以及问题的解答。期待在那里见到你!

和往常一样,让我们从布局开始。我将基础 Compose 组件命名为"TimerShaderScreen"。在组件内部,我们将从一个"Column"容器开始,该容器顶部包含一个输入字段,后面跟着一个"Box"。这个"Box"在同一层级上包含一个将应用着色器的"Box"和一个显示当前计时器值的"Text"元素(这一点很重要)。从技术上讲,我们可以只使用一个"Box",但这会使着色器稍微复杂一些。为了简化本教程,我选择了稍微复杂的构图,以使着色器保持简洁。在这个"Box"之后,有一个用于启动计时器的按钮。我提供了一个布局图,以便更清晰地展示,但总的来说,布局本身已经非常简单了。

接下来,让我们添加必要的变量并定义整个布局。由于我们尚未准备好着色器,因此你可以暂时跳过"RuntimeShader"变量,只需运行项目并确保一切正常即可。我想强调一个重要的细节:如果你将着色器应用于一个不包含任何内容的"Box",则需要为其设置背景颜色。这确保它参与合成,从而使我们的效果可见。但是,务必在应用"graphicsLayer"之后设置背景颜色。否则,只会渲染颜色,着色器将不可见。目前,布局应该如下所示:

kotlin 复制代码
@Composable
fun TimerShaderScreen(paddingValues: PaddingValues) {
    var startValue by remember { mutableStateOf(20) }
    var percentage by remember { mutableFloatStateOf(0.0f) }
    var isRunning by remember { mutableStateOf(false) }
    val seconds = remember { mutableStateOf(startValue) }

    // circularTimeShader是一个包含着色器代码的字符串对象,我们将在下面的部分中编写
    val shader = remember { RuntimeShader(circularTimerShader) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        OutlinedTextField(
            value = if (startValue != 0) startValue.toString() else "",
            onValueChange = {
                startValue = it.toIntOrNull() ?: 0
            },
            label = { Text("Start value") },
            modifier = Modifier.width(200.dp)
        )
        Box(
            modifier = Modifier.size(250.dp),
            contentAlignment = Alignment.Center
        ) {
            Box(
                modifier = Modifier
                    .size(250.dp)
                    .onSizeChanged {
                        shader.setFloatUniform("resolution", it.width.toFloat(), it.height.toFloat())
                    }
                    .graphicsLayer {
                        shader.setFloatUniform("percentage", percentage)
                        renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect()
                    }
                    .background(color = Color.Black), // <-- 我们必须添加一些颜色,否则空框就会从构图中移除,就看不出效果了
            )

            Text(
                modifier = Modifier
                    .fillMaxWidth(),
                textAlign = TextAlign.Center,
                text = seconds.toString(),
                fontSize = 50.sp,
                fontWeight = FontWeight.Thin,
                color = Color.White
            )
        }

        OutlinedButton(
            onClick = { isRunning = !isRunning },
            modifier = Modifier.padding(top = 16.dp)
        ) {
            Text(if (isRunning) "Stop" else "Start")
        }
    }
}

这是它在手机上的实际显示效果:

剩下的就是添加计时器本身的逻辑,然后我们就可以开始进入正文了。首先,让我们创建一个 LaunchedEffect 来在计时器运行时更新其值。同时,我们将在此代码块中计算着色器的 percentage。由于我们的着色器会从计时器的起始值向下过渡到零,因此我们需要对 percentage 变量进行归一化,使其相应地从 0 变为 1。代码如下:

kotlin 复制代码
LaunchedEffect(isRunning) {
    val startTime = System.currentTimeMillis()
    while (isRunning) {
        if (seconds.value < 0) { isRunning = false } // 时间到时停止倒计时

        val elapsedTime = System.currentTimeMillis() - startTime // 已用时间(单位是毫秒)
        percentage = (elapsedTime / 1000f) / startValue // 将经过的时间归一化为[0,1]

        // 将百分比限制在 [0, 1] 之间以避免过冲
        percentage = percentage.coerceIn(0f, 1f)

        // 计算剩余秒数
        seconds.value = ((startValue - (elapsedTime / 1000f))).toInt()

        delay(10) // 延迟以控制更新频率
    }
}

Compose 部分就到此为止;让我们继续编写着色器吧!

首先,让我们定义来自 UI 的变量,以及着色器的最低设置。现在,我们将返回像素与归一化坐标中心的距离,作为颜色:length(uv)。如果这部分内容不太清楚,强烈建议你查看我的 教程,了解如何在 Android 中使用着色器。

kotlin 复制代码
private val circularTimerShader = """
    uniform float time;
    uniform float percentage;
    uniform vec2 resolution;
    uniform shader image;

    vec4 main(float2 fragCoord) {
        float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
        uv.x *= resolution.x / resolution.y;
        return vec4(length(uv));
    }
""".trimIndent()

因此,目前我们得到的大致如下:

现在,让我们尝试在标准笛卡尔平面上绘制一个"栅栏",即一系列垂直的条形。如何实现这个效果?首先,我们将 y 轴上的所有值限制在零以下。着色器现在如下所示:

glsl 复制代码
vec4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
    uv.x *= resolution.x / resolution.y;

    vec3 color = vec3(1.) * step(uv.y, 0.0);

    return vec4(color, 1.0);
}

接下来,我们还将沿 x 轴裁剪掉一半的区域。记住,我们的 uv 坐标偏移了 0.5,使零点位于中心。这样,我们也可以沿 x 轴在零点处裁剪,只留下右上象限。

glsl 复制代码
float fence = step(0.0, uv.x);
vec3 color = vec3(1.) * step(uv.y, 0.0) * fence;

现在,如果我们将 x 轴上的坐标乘以 10,并只取小数部分,我们将得到如下所示的 x 轴值:[0...1, 0...1, 0...1, ...]。再将它们平移 0.5,我们就得到了"栅栏"。下面,我演示了构建垂直条的三个步骤。

glsl 复制代码
vec4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
    uv.x *= resolution.x / resolution.y;

    float fence = step(0.0, fract(uv.x * 10.) - 0.5);
    vec3 color = vec3(1.0) * step(uv.y, 0.0) * fence;

    return vec4(color, 1.0);
}

现在到了最激动人心的部分:如何从笛卡尔坐标系过渡到极坐标系?换句话说,如何创建相同的"栅栏",但使其看起来像圆形?让我们来弄清楚。

我们习惯用两个值来描述函数:xy。这使我们能够确定像素在平面上的精确位置并为其分配颜色。在我们的例子中,坐标系是 uv。为了垂直裁剪,我们使用 y;为了水平裁剪"栅栏"的各个部分,我们使用 x

极坐标系与此非常相似,但我们使用的不是水平轴和垂直轴,而是半径(与原点的距离)和角度。

换句话说,由于这两个坐标系都包含两个分量,理解它们的含义使我们能够相对轻松地将笛卡尔坐标系中使用的公式和技术应用于极坐标系。然而,最终的图像看起来会"弯曲"成一个圆形。让我们尝试将"栅栏"转换为极坐标:

glsl 复制代码
vec2 cartesianToPolar(vec2 uv) {
    float r = length(uv);
    float theta = atan(uv.y, uv.x);
    return vec2(r, theta);
}vec4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
    uv.x *= resolution.x / resolution.y;

    // 转换为极坐标
    vec2 polar = cartesianToPolar(uv);

    float r = polar.x; // 半径

    float theta = polar.y; // 角度

    float fence = step( 0.5, fract(theta * 10.0));
    vec3 color = vec3(1.) * step(0.5, r) * fence;

    return vec4(color, 1.0);
}

转存失败,建议直接上传图片文件](<转存失败,建议直接上传图片文件

但是,你可能会注意到,光线的宽度随着远离中心而增加。我们可以添加补偿来消除这种影响。

glsl 复制代码
// 宽度已调整的进度条
float width = 0.02; // Bar width
float adjustedWidth = width / max(r, 0.001); // 补偿径向缩放
float fence = step( 0.5 - adjustedWidth, fract(theta * 10.0));

接下来,我们不仅需要限制内半径,还需要限制外半径,以创建一个环,而不是无限延伸的光线。

glsl 复制代码
// 将波浪蒙版均匀地涂抹在进度条上
float barRadius = 0.3;
float barMask = smoothstep(barRadius+0.01, barRadius, r);

总的来说,此时它看起来应该像这样:

glsl 复制代码
vec4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
    uv.x *= resolution.x / resolution.y;

    float width = 0.02; // 进度条宽度

    // 转化为极坐标
    vec2 polar = cartesianToPolar(uv);
    float r = polar.x;

    // 半径
    float theta = polar.y;

    // 角度
    float innerMask = step(0.3, r);

    // 宽度已调整的进度条
    float adjustedWidth = width / max(r, 0.001); // 补偿径向缩放
    float fence = step( 0.5 - adjustedWidth, fract(theta * 10.0));

    // 将波浪蒙版均匀地涂抹在进度条上
    float barRadius = 0.3;
    float barMask = smoothstep(barRadius+0.01, barRadius, r);

    // 结合进度条和内层蒙版
    float combinedMask = barMask * fence * innerMask;

    vec3 col = vec3(1.0) * combinedMask;

    return vec4(col, 1.0);
}

最后一步是根据 percentage 的值增加列的大小。这里需要注意的是,角度 theta 的当前范围是从 -PiPi,因此需要对其进行归一化,即将其转换为从 01 的范围。这样可以更容易地将其与 percentage 的值对齐,因为 percentage 的值也是从 01 变化的。

glsl 复制代码
// 还将其移动,使零位于顶部而不是左侧
float normalizedAngle = mod((theta + 1.57079632679) / 6.28318530718, 1.0); 

现在我们可以为"wave"应用另一个蒙版,它将取决于 percentage 变量的当前值。

glsl 复制代码
// 计算条形中心的波浪蒙版
float wave = smoothstep(fixPercentage, fixPercentage - 0.1, centerNormalizedAngle);
float waveMask = smoothstep(0.32 + 0.1 * wave, 0.31 + 0.1 * wave, r);

我们还需要将这个波浪的值添加到 barRadius 中,并在最后将整体遮罩乘以 waveMask。此外,我将最终 Alpha 值的常量值替换为组合遮罩的值。这样,我们现在得到了一个几乎完整的效果。

glsl 复制代码
// 其余代码 ...

float barRadius = 0.3 + 0.1 * wave; // 使用波形蒙版设置条形半径

// ...

float combinedMask = barMask * fence * innerMask * waveMask;
vec3 col = vec3(1.0) * combinedMask;

return vec4(col, combinedMask);

我想在本节中添加的最后一点是稍微优化一下蒙版。你可能已经注意到,每个列的左右边缘半径不同,但我希望它们的高度一致。这样可以使效果看起来更简洁一些。以下是实现方法:

glsl 复制代码
// 在每个条形的中心采样波形蒙版
float barIndex = floor(theta * 10.0); // 确定条的索引
float centerTheta = (barIndex + 0.5) / 10.0; // 条的中心角
float centerNormalizedAngle = mod((centerTheta + 1.57079632679) / 6.28318530718, 1.0);

// 计算条形中心的波浪蒙版
float wave = smoothstep(fixPercentage, fixPercentage - 0.1, centerNormalizedAngle);
float waveMask = smoothstep(0.32 + 0.1 * wave, 0.31 + 0.1 * wave, r);

总的来说,核心效果现在已经完成。然而,在着色器中,通常情况下,99% 复杂而有趣的工作构成了效果的基础,但单独来看,效果可能看起来简单平淡。正是这最后的 1% 的润色,才能彻底改变一切!

让我们为着色器添加一个渐变。我使用了最简单的一个,它在 ShaderToy 中启动新项目时默认创建(你可以访问网站并点击右上角的"新建"按钮查看)。我把它移到了一个单独的方法中,并将其命名为 gradient

glsl 复制代码
 vec3 gradient(vec2 uv, float t) {
        return 0.5 + 0.5 * cos(t + uv.xyx + vec3(0, 2, 4));
 }

现在,我不再将最终颜色乘以 vec3(1.)(即白色),而是将其乘以我们刚刚创建的渐变。此外,我还对其余区域应用了相同的渐变,但 Alpha 值非常低,这为整个屏幕增添了微妙的辉光。

请注意,要看到这个效果,我们还必须从 Compose 代码中传递 time

现在,让我们转到项目的可组合部分,并在文本变化时添加动画和模糊效果。我不会详细介绍这一点,因为这超出了本课的范围。但是,如果本教程完全不包含代码,感觉不太对劲。所以,以下是可组合部分(包含时间更新和文本动画)的最终版本:

kotlin 复制代码
@Composable
fun TimerShaderScreen(paddingValues: PaddingValues) {
    var startValue by remember { mutableStateOf(20) }
    var percentage by remember { mutableFloatStateOf(0.0f) }
    var isRunning by remember { mutableStateOf(false) }

    val shader = remember { RuntimeShader(circularTimerShader) }

    var time by remember { mutableStateOf(0f) }

    LaunchedEffect(null) {
        while (true) {
            time += 0.01f
            delay(10)
        }
    }

    val seconds = remember { mutableStateOf(startValue) }

    LaunchedEffect(isRunning) {
        val startTime = System.currentTimeMillis()
        while (isRunning) {
            if (seconds.value < 0) { isRunning = false } // 时间到时停止倒计时

            val elapsedTime = System.currentTimeMillis() - startTime // 已用时间(单位是毫秒)
            percentage = (elapsedTime / 1000f) / startValue // 将经过的时间归一化为[0,1]

            // 将百分比限制在 [0, 1] 之间以避免过冲
            percentage = percentage.coerceIn(0f, 1f)

            // 计算剩余秒数
            seconds.value = ((startValue - (elapsedTime / 1000f))).toInt()

            delay(10) // 延迟以控制更新频率
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        OutlinedTextField(
            value = if (startValue != 0) startValue.toString() else "",
            onValueChange = {
                startValue = it.toIntOrNull() ?: 0
            },
            label = { Text("Start value") },
            modifier = Modifier.width(200.dp)
        )
        Box(
            modifier = Modifier.size(250.dp),
            contentAlignment = Alignment.Center
        ) {
            Box(
                modifier = Modifier
                    .size(250.dp)
                    .onSizeChanged {
                        shader.setFloatUniform("resolution", it.width.toFloat(), it.height.toFloat())
                    }
                    .graphicsLayer {
                        shader.setFloatUniform("percentage", percentage)
                        shader.setFloatUniform("time", time)
                        renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect()
                    }
                    .background(color = Color.Black),
            )
            var previousSeconds by remember { mutableStateOf(seconds.value.coerceAtLeast(0)) }

            AnimatedContent(
                targetState = seconds.value.coerceAtLeast(0),
                transitionSpec = {
                    (slideInVertically(animationSpec = tween(500)) { height -> -height } + fadeIn(
                        animationSpec = tween(
                            500
                        )
                    ))
                        .togetherWith(slideOutVertically(animationSpec = tween(500)) { height -> height } + fadeOut(
                            tween(500)
                        ))
                },
                label = "CountdownAnimation"
            ) { targetSeconds ->
                // 检测是否正在转换
                val isTransitioning = targetSeconds != previousSeconds

                // 过渡期间模糊动画
                val blurRadius by animateFloatAsState(
                    targetValue = if (isTransitioning) 30f else 0f, // 过渡时模糊
                    animationSpec = tween(durationMillis = 500) // 匹配过渡持续时间
                )

                // 转换完成后更新 previousSeconds
                LaunchedEffect(targetSeconds) {
                    previousSeconds = targetSeconds
                }

                Text(
                    modifier = Modifier
                        .fillMaxWidth()
                        .graphicsLayer {
                            // 应用动画模糊效果
                            renderEffect = RenderEffect.createBlurEffect(
                                blurRadius, blurRadius, android.graphics.Shader.TileMode.CLAMP
                            ).asComposeRenderEffect()
                        },
                    textAlign = androidx.compose.ui.text.style.TextAlign.Center,
                    text = targetSeconds.toString(),
                    fontSize = 50.sp,
                    fontWeight = androidx.compose.ui.text.font.FontWeight.Thin,
                    color = Color.White
                )
            }
        }

        OutlinedButton(
            onClick = { isRunning = !isRunning },
            modifier = Modifier.padding(top = 16.dp)
        ) {
            Text(if (isRunning) "Stop" else "Start")
        }
    }
}

以及最终的着色器代码:

glsl 复制代码
uniform float time;
uniform float percentage;
uniform vec2 resolution;
uniform shader image;vec2 cartesianToPolar(vec2 uv) {
    float r = length(uv);
    float theta = atan(uv.y, uv.x);
    return vec2(r, theta);
}vec3 gradient(vec2 uv, float t) {
    return 0.5 + 0.5 * cos(t + uv.xyx + vec3(0, 2, 4));
}vec4 main(float2 fragCoord) {
    float2 uv = fragCoord / resolution - 0.5; // 归一化坐标
    uv.x *= resolution.x / resolution.y;

    float width = 0.02; // 条形宽度

    // 转换为极坐标
    vec2 polar = cartesianToPolar(uv);
    float r = polar.x; // 半径

    float theta = polar.y;    // 角度

    float innerMask = step(0.3, r);

    // 将顶部设为零,使角度标准化
    float normalizedAngle = mod((theta + 1.57079632679) / 6.28318530718, 1.0);

    // 固定百分比补偿
    float fixPercentage = percentage + percentage * 0.1;

    // 在每个条形的中心采样波形蒙板
    float barIndex = floor(theta * 10.0); // 确定条的索引
    float centerTheta = (barIndex + 0.5) / 10.0; // 条的中心角
    float centerNormalizedAngle = mod((centerTheta + 1.57079632679) / 6.28318530718, 1.0);

    // 计算条形中心的波浪蒙板
    float wave = smoothstep(fixPercentage, fixPercentage - 0.1, centerNormalizedAngle);
    float waveMask = smoothstep(0.32 + 0.1 * wave, 0.31 + 0.1 * wave, r);

    // 宽度已调整的条形图案
    float adjustedWidth = width / max(r, 0.001); // 补偿径向缩放
    float fence = step( 0.5 - adjustedWidth, fract(theta * 10.0));

    // 将波浪蒙版均匀地涂抹在吧台上
    float barRadius = 0.3 + 0.1 * wave; // 使用波形蒙版设置条形半径
    float barMask = smoothstep(barRadius+0.01, barRadius, r);

    // 结合条形和内层蒙版
    float combinedMask = barMask * fence * innerMask * waveMask;

    vec3 grad = gradient(uv, time);
    vec3 col = grad * combinedMask;

    return vec4(col + grad * 0.1, combinedMask + length(uv));
}

感谢阅读!如果你觉得我的实验有趣且讲解有帮助,欢迎加入我的Telegram频道或在Twitter (X)上关注我。你的支持意义重大,并激励着我。祝你撸码愉快!

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
安卓开发者42 分钟前
Android RxJava 组合操作符实战:优雅处理多数据源
android·rxjava
阿华的代码王国1 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼1 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jerry说前后端1 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
一条上岸小咸鱼8 小时前
Kotlin 基本数据类型(一):Numbers
android·前端·kotlin
Huntto8 小时前
最小二乘法计算触摸事件速度
android·最小二乘法·触摸事件·速度估计
一笑的小酒馆8 小时前
Android中使用Compose实现各种样式Dialog
android
红橙Darren9 小时前
手写操作系统 - 编译链接与运行
android·ios·客户端
鹏多多.12 小时前
flutter-使用device_info_plus获取手机设备信息完整指南
android·前端·flutter·ios·数据分析·前端框架