本文译自「Deform the canvas」,原文链接https://medium.com/@off.mind.by/deform-the-canvas-57dc59bec42a,由Alex Volkov发布于2025年8月2日。
大家好!使用着色器时,我总是着迷于如何轻松创建看起来精致而昂贵的效果。今天的文章就是另一个很好的例子。最近,我的 Telegram 频道的一位订阅者问我,如何在用手指拖动视图时创建拉伸效果。自然而然地,我立刻想到了用着色器来实现。现在结果已经出来了,我很高兴分享我的构建过程。和往常一样,这篇文章分为几个部分:第一部分展示了 Compose 的简单设置,第二部分逐步讲解着色器,最后,我们将在 Compose 中添加一些小细节,这些细节实际上构成了 90% 的视觉效果——尽管这可能感觉有点不公平。
最终效果如下:
在深入探讨之前,我想提醒你,我并没有为所有效果创建教程。不过,所有效果都可以在我的 GitHub (链接:https://github.com/AleksiejVolkov/runtimeshaders)上找到。你还可以在我的 Telegram 频道 中找到视频、新效果的公告以及问题的解答。期待在那里见到你!
让我们从最基本的 Compose 设置开始。在本教程中,我不会包含任何背景图片或额外的样式。我们会尽可能地保持简洁。我们只需要一个应用着色器的容器、一些着色器本身的参数,以及一个放置在着色器盒内的菜单或列表的基本模拟。以下是我们开始所需的基本设置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
|
shader - 这是着色器本身,我们将在第二部分中编写它。
targetPercentage、percentage 和 pressed - 这些控制传递给着色器的效果强度。我们需要它们来为用户抬起手指后的“反弹”效果添加动画效果。思路很简单:当有活动触摸时,强度为 1(最大值),当用户抬起手指时,我们将其动画化为 0。我们将使用自定义的 ElasticOutEasing 来代替常规的线性动画,我将在最后一节中对其进行描述。
fingerPosition 和 fingerStartPosition - 我们只将增量向量传递给着色器,这意味着我们关心方向和强度(向量长度)。拉伸始终从中心开始(我发现这比从精确的触摸点开始更美观)。因此,我们存储两个值,并将它们的差值传递给着色器。
接下来,在 onSizeChanged 中,我们将视图大小传递给着色器。在 pointerInput 中,我们跟踪拖动并计算增量向量。最后,在 graphicsLayer 中,我们将增量和效果强度传递给着色器。
接下来是包含将要拉伸的视图示例的代码块。它实际上只是一段基本的占位符代码,所以我认为不值得详细分析。我将其包含在这里只是为了方便——这样你就可以复制它,而不必担心自己编写它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
Compose 的设置就到这里!让我们开始编写着色器吧!
我们从最简单的着色器开始。下面是输出输入的极简代码,无需进行任何重大更改。我唯一添加的是一个辅助方法,方便以后修改。它叫做 GetImageTexture。从技术上讲,你可以使用 image.eval(fragCoord) 返回所有未更改的图像,但是一旦在重新映射图像之前开始修改坐标,就需要处理宽高比和图像平移——这基本上就是使用以画布中心为中心的坐标系。这个方法可以处理这些问题。你只需向它传递标准化的 UV 坐标、自定义中心点和分辨率,它就会返回正确的结果。如果你现在使用这些设置运行着色器,你应该会看到与不使用着色器时完全相同的图像。这是一个好兆头!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
你在这里看到的应该非常熟悉——几乎所有我的着色器都是这样启动的,说实话,大多数其他着色器也是如此。我再重复一遍:我们获取 fragCoord,它是每个像素相对于视图画布的位置。在此基础上,我们创建一个标准化的 UV 坐标系,根据宽高比进行调整,并偏移 0.5——这将原点置于视图的正中央。并非每个人都这样做,但我觉得这样更方便。它可以“免费”地实现很多效果,比如镜像行为,因为我们只围绕中心计算一次所有内容,而不是分别处理每条边。下面是我们画布的示意图。
好了,现在该了解一下我们想要实现的效果了。我们需要从中心(在当前实现中)沿着特定向量拉伸画布,同时保持其余部分不变。那么,如何拉伸画布呢?其实,最简单的方法就是乘以一个数字!让我们测试一下——添加一个比例变量并尝试一下。最后,我们将 UV 乘以这个比例向量。
1 2 |
|
尝试使用不同的scale值来更好地感受效果。正如你所注意到的——使用1不会改变任何值,因为乘以1后值保持不变。小于1的值会拉伸图像,而大于1的值会压缩图像。记住这一点——我们稍后会计算缩放强度并将其从1中减去,这样当缩放为零时,效果也为零,这意味着我们乘以了一个单位向量。
太好了,现在让我们将触摸位置添加到计算中。简单回顾一下——我们从触摸中接收了一个位移向量。因此,当手指触摸屏幕时,向量为 (0, 0)。如果我们将手指向右移动 20 像素,则得到 (20, 0)。我们首先需要对这个向量进行归一化,并将其乘以宽高比。
1 2 |
|
如果我们将手指向左移动 20 像素,则会得到一个 (-20, 0) 的向量。因此,我们不会直接使用这个向量作为比例,而是取其长度。它看起来会像这样:
1
|
|
记住,没有效果意味着乘以 1,而不是乘以 0?这就是为什么在使用比例向量时,我们在应用之前先将其从 1 中减去:
1
|
|
如果一切设置正确,我们现在应该能够控制沿两个轴的拉伸。大致如下所示:
一切看起来都很好——现在我们只需要在一个方向上应用拉伸。为此,我们可以使用向量的点积。原理很简单:如果向量指向同一方向,则此运算返回正值;如果向量指向相反方向,则返回负值。我强烈建议你在专用资源上阅读更多关于此运算(以及其他向量运算)的内容,因为线性代数是着色器中一切的基础。在这里,我将向你展示它在实践中的工作原理!
1 2 |
|
如你所见,效果已经近乎完美!但我不太喜欢另一侧在另一个方向上变形(即被压缩)。这是点积的副作用——当它返回负值时,缩放比例也会变为负值,画布的这一部分会被挤压。我希望保持视图的这一部分不变,所以我添加了一个从零到定义最大值的线性插值。
1
|
|
最后一步是乘以“效果强度”,我们通过百分比参数传递该强度(也许这不是最好的命名,但我习惯这样称呼它)。这不会改变拖动过程中的行为,但它可以让我们在拖动结束后平滑地将视图恢复到原始状态。
1
|
|
就是这样!着色器已准备就绪。以下是完整代码——尽管最终的视觉效果看起来丰富而复杂,但它简洁明了。在最终版本中,我还限制了 x 轴和 y 轴上的最大缩放值。你可以根据需要随意调整这些值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
现在来看看我从 Compose 中留下的部分——ElasticOutEasing。在 Compose 中创建动画时,你可以使用内置的缓动函数,也可以定义自己的缓动函数。我使用了一个简单的弹性缓动函数示例,如下所示:
1 2 3 4 5 6 7 8 |
|
此缓动函数在动画结束时创建类似弹跳的效果。它一开始很快,然后略微超过目标并稳定下来,模仿弹簧的行为。其核心思想是将指数衰减 (2^-10t) 与正弦波相结合,以模拟弹性运动。它在起始 (0) 和结束 (1) 处返回精确值,但在两者之间添加了一个抖动。
你也可以使用常规的线性缓动,但结果看起来会更加平淡。这正是我在开头提到的——这个小细节为整体效果的流畅度和令人满意的体验贡献了 90%!
感谢你的阅读!如果你觉得我的实验有趣且我的解释对你有帮助,欢迎加入我的 Telegram 频道 或在 Twitter (X) 上关注我。这个项目只是我的一个爱好,说实话,我的动力很大程度上取决于收到的反馈——所以我非常高兴在频道里见到你。如果你愿意的话,请将这篇文章分享到你的社交媒体上,我将不胜感激。祝你撸码愉快!