探索 CSS Paint API:Blob 动画

Avatar of Temani Afif
Temani Afif

DigitalOcean 提供适用于旅程各个阶段的云产品。 立即开始使用 价值 200 美元的免费积分!

碎片化效果之后,我将处理另一种有趣的动画:blob!我们都同意这种效果很难用 CSS 实现,因此我们通常会使用 SVG 来创建这些粘稠的形状。 但现在强大的 Paint API 可用,使用 CSS 不仅可能,而且当浏览器支持到位后,它可能是一种更可取的方法。

探索 CSS Paint API 系列


这是我们要制作的内容。 目前只有 Chrome 和 Edge 支持,因此我们在操作过程中请在其中一个浏览器中查看。

实时演示 (仅 Chrome 和 Edge)

构建 blob

让我们了解使用经典 <canvas> 元素绘制 blob 的逻辑,以便更好地说明形状

说到 blob,我们也在谈论扭曲圆圈的总体形状,因此我们可以使用该形状作为我们的基础。 我们定义了围绕圆圈放置的 N 个点(以绿色显示)。

const CenterX = 200;
const CenterY = 200;
const Radius = 150;
const N = 10;
var point = [];

for (var i = 0; i < N; i++) {
  var x = Math.cos((i / N) * (2 * Math.PI)) * Radius + CenterX;
  var y = Math.sin((i / N) * (2 * Math.PI)) * Radius + CenterY;
  point[i] = [x, y];
}

考虑到中心点(由 CenterX/CenterY 定义)和半径,我们使用一些基本的三角函数计算每个点的坐标。

之后,我们使用 quadraticCurveTo() 在我们的点之间绘制三次贝塞尔曲线。 为此,我们引入了更多点(以红色显示),因为三次贝塞尔曲线需要一个起点、一个控制点和一个终点

红色的点是起点和终点,而绿色的点可以是控制点。 每个红色点都放置在两个绿色点之间的中点。

ctx.beginPath(); /* start the path */
var xc1 = (point[0][0] + point[N - 1][0]) / 2;
var yc1 = (point[0][1] + point[N - 1][1]) / 2;
ctx.moveTo(xc1, yc1);
for (var i = 0; i < N - 1; i++) {
  var xc = (point[i][0] + point[i + 1][0]) / 2;
  var yc = (point[i][1] + point[i + 1][1]) / 2;
  ctx.quadraticCurveTo(point[i][0], point[i][1], xc, yc);
}
ctx.quadraticCurveTo(point[N - 1][0], point[N - 1][1], xc1, yc1);
ctx.closePath(); /* end the path */

现在我们要做的就是更新控制点的位置以创建 blob 形状。 让我们尝试用一个点,并添加以下内容

point[3][0]= Math.cos((3 / N) * (2 * Math.PI)) * (Radius - 50) + CenterX;
point[3][1]= Math.sin((3 / N) * (2 * Math.PI)) * (Radius - 50) + CenterY;

第三个点最靠近圆圈的中心(大约 50 像素),而我们的三次贝塞尔曲线完美地跟随运动以保持弯曲的形状。

让我们对所有点做同样的事情。 我们可以使用相同的通用想法,将以下现有行更改

var x = Math.cos((i / N) * (2 * Math.PI)) * Radius + CenterX;
var y = Math.sin((i / N) * (2 * Math.PI)) * Radius + CenterY;

…为

var r = 50*Math.random();
var x = Math.cos((i / N) * (2 * Math.PI)) * (Radius - r) + CenterX;
var y = Math.sin((i / N) * (2 * Math.PI)) * (Radius - r) + CenterY;

每个点都偏移了 0 到 50 像素之间的随机值,从而使每个点以略微不同的量更靠近中心。 因此,我们得到了 blob 形状!

现在,我们将该形状作为蒙版应用于使用 CSS Paint API 的图像。 由于我们正在处理 blob 形状,因此最好考虑正方形元素(高度等于宽度),其中半径等于宽度或高度的一半。

让我们使用 CSS 变量 (N) 来控制点的数量。

我强烈建议您阅读 我之前文章 的第一部分,以了解 Paint API 的结构。

每次代码运行时,由于随机配置,我们都会得到一个新的形状。

让我们来动画化它!

绘制 blob 很好,但对其进行动画化更好! 毕竟,对 blob 进行动画化是本文的主要目的。 我们将看到如何使用相同的代码基础来创建不同类型的粘稠 blob 动画。

主要思想是平滑地调整点的位置(无论是全部还是部分)以在两个形状之间过渡。 让我们从最基本的一个开始:通过更改一个点的位置,从圆形过渡到 blob。

Animated gif shoeing a cursor hovering the right edge of a circular image. The right side of the image caves in toward the center of the shape on hover, and returns when the cursor leaves the shape.
实时演示 (仅 Chrome 和 Edge)

为此,我引入了一个新的 CSS 变量 B,并为其应用了 CSS 过渡。

@property --b{
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}
img {
  --b:0;
  transition:--b .5s;
}
img:hover {
  --b:100
}

我在 paint() 函数中获取此变量的值,并使用它来定义点的坐标。

如果您检查嵌入式链接演示中的代码,您会注意到这一点

if(i==0) 
  var r = RADIUS - B;
else
  var r = RADIUS

所有点都具有固定位置(由形状的半径定义),但第一个点特别具有可变位置 (RADIUS - B )。 悬停时,B 的值从 0 更改为 100,使我们的点更靠近中间,同时创建这种酷炫的效果。

让我们对更多点执行此操作。 并非所有点,而是偶数点。 我将按以下方式定义位置

var r = RADIUS - B*(i%2);
An animated gif showing a cursor hovering over a circular image. The shape of the image morphs to a sort of star-like shape when the cursor enters the image, then returns when the cursor leaves.
实时演示 (仅 Chrome 和 Edge)

我们有了第一个 blob 动画! 我们定义了 20 个点,并使其中一半更靠近中心。

通过简单地调整 CSS 变量,我们可以轻松获得不同的 blob 变体。 我们定义点的数量和 B 变量的最终值。

实时演示 (仅 Chrome 和 Edge)

现在让我们尝试一些随机的内容。 与其使用固定值来移动我们的点,不如让我们让该值随机移动它们。 我们之前使用了以下内容

var r = RADIUS - B*(i%2);

让我们将其更改为以下内容

var r = RADIUS - B*random();

…其中 random() 为我们提供 [0 1] 范围内的值。 换句话说,每个点都会移动 0 到 B 之间的随机值。 以下是我们得到的结果

实时演示 (仅 Chrome 和 Edge)

看到了吗? 我们使用相同的代码结构获得了另一个酷炫的动画。 我们只更改了一条指令。 我们可以将该指令更改为变量,这样我们就可以决定是否使用统一配置或随机配置,而无需更改我们的 JavaScript。 我们引入另一个变量 T,它像布尔值一样

if(T == 0) 
  var r = RADIUS - B*(i%2);
else 
  var r = RADIUS - B*random();

我们有两种动画,并且借助 T 变量,我们可以决定使用哪一种。 我们可以使用 N 控制点的数量,使用变量 V 控制距离。 是的,很多变量,但不用担心,我们将在最后总结所有内容。

random() 函数在做什么?

它与我在上一篇文章中使用的函数相同。 我们在那里看到,我们不能依赖默认的内置函数,因为我们需要一个能够控制种子的随机函数,以确保我们始终获得相同的随机值序列。 因此,种子值也是我们可以控制以获得不同 blob 形状的另一个变量。 手动更改该值并查看结果。

上一篇文章 中,我提到 Paint API 消除了 CSS 方面的所有复杂性,这使我们能够更灵活地创建复杂的动画。 例如,我们可以将我们到目前为止所做的内容与关键帧和 cubic-bezier() 结合起来

实时演示 (仅 Chrome 和 Edge)

演示包含另一个示例,使用我在 上一篇文章 中详细介绍的抛物线。

控制点的位置

在我们到目前为止创建的所有 blob 中,我们都考虑了点相同的运动方式。 无论我们使用统一配置还是随机配置,我们始终将点从边缘移动到圆圈的中心,并遵循一条直线。

现在让我们看看如何控制该运动,以获得更多动画。 该逻辑背后的想法很简单:我们以不同的方式移动 xy

之前我们是这样做的

var x = Math.cos((i / N) * (2 * Math.PI)) * (Radius - F(B)) + CenterX;
var y = Math.sin((i / N) * (2 * Math.PI)) * (Radius - F(B)) + CenterY;

…其中 F(B) 是基于保存过渡的变量 B 的函数。

现在,我们将改为以下内容

var x = Math.cos((i / N) * (2 * Math.PI)) * (Radius - Fx(B)) + CenterX;
var y = Math.sin((i / N) * (2 * Math.PI)) * (Radius - Fy(B)) + CenterY;

…其中我们以不同的方式更新 xy 变量以创建更多动画。 让我们尝试一些。

单轴运动

对于这一项,我们将一个函数设置为 0,并将另一个函数保持与之前相同。换句话说,一个坐标在动画过程中保持固定。

如果我们这样做

Fy(B) = 0

… 我们将得到

A cursor hovers two circular images, which has points that pull inward from the left and right edges of the circle to created jagged sides along the circles.
实时演示 (仅适用于 Chrome 和 Edge)

这些点仅在水平方向上移动以获得另一种效果。我们可以轻松地对另一个轴执行相同的操作,方法是将 Fx(B)=0 (查看演示)。

我认为你明白了。我们所要做的就是调整每个轴的函数以获得不同的动画。

向左或向右移动

让我们尝试另一种运动。与其让这些点收敛到中心,不如让它们向同一个方向移动(向右或向左)。我们需要一个基于点位置的条件,该位置由角度定义。

Illustration showing the blue outline of a circle with 8 points around the shape and thick red lines bisecting the circle to show the axes.

我们有两组点:一组在 [90deg 270deg] 范围内(左侧),其余点位于形状的右侧。如果我们考虑索引,我们可以用不同的方式表达范围,例如 [0.25N 0.75N],其中 N 是点数。

诀窍是为每组提供不同的符号

var sign = 1;
if(i<0.75*N && i>0.25*N) 
  sign = -1; /* we invert the sign for the left group */
if(T == 0) 
  var r = RADIUS - B*sign*(i%2);
else 
  var r = RADIUS - B*sign*random();
var x = Math.cos((i / N) * (2 * Math.PI)) * r + cx;

我们得到了

An animated gif showing a cursor entering the right side of a circular image, which has points the are pulled toward the center of the circle, then return once the cursor exits.
实时演示 (仅适用于 Chrome 和 Edge)

我们能够获得相同的方向,但有一个小缺点:一组点会超出遮罩区域,因为我们正在增加一些点的距离,同时减少其他点的距离。我们需要减小圆圈的大小,以便为所有点留出足够的空间。

我们只需使用定义 B 变量最终值的 V 值来减小圆圈的大小。换句话说,这是点可以达到的最大距离。

我们最初的形状(由灰色区域表示,并用绿色点定义)将覆盖更小的区域,因为我们将使用 V 的值减小半径值。

const V = parseFloat(properties.get('--v'));
const RADIUS = size.width/2 - V;
实时演示 (仅适用于 Chrome 和 Edge)

我们解决了点超出问题,但还有一个小的缺点:可悬停区域仍然相同,因此效果甚至在光标触及图像之前就开始。如果我们还能减小该区域,使所有内容保持一致,那就太好了。

我们可以使用一个额外的包装器和一个 负边距技巧。这是一个 演示。这个技巧很简单

.box {
  display: inline-block;
  border-radius: 50%;
  cursor: pointer;
  margin: calc(var(--v) * 1px);
  --t: 0;
}

img {
  display: block;
  margin: calc(var(--v) * -1px);
  pointer-events: none;
  -webkit-mask: paint(blob);
  --b: 0;
  transition:--b .5s;
}
.box:hover img {
  --b: var(--v)
}

额外的包装器是一个 inline-block 元素。它内部的图像具有等于 V 变量的负边距,这会减小形状框的整体大小。然后,我们禁用图像元素的悬停效果(使用 pointer-events: none),这样只有框元素才能触发过渡。最后,我们在框元素上添加一些边距,以避免任何重叠。

与之前的效果一样,此效果也可以与 cubic-bezier() 和关键帧组合使用,以获得更酷的动画。以下是一个示例,它使用 我的正弦曲线 为悬停效果提供晃动效果。

实时演示 (仅适用于 Chrome 和 Edge)

如果我们添加一些变换,我们可以创建一种奇怪(但很酷)的滑动动画

A circular image is distorted and slides from right to left on hover in this animated gif.
实时演示 (仅适用于 Chrome 和 Edge)

圆周运动

让我们处理另一个有趣的运动,它将使我们能够创建无限且“逼真”的 blob 动画。与其将我们的点从一个位置移动到另一个位置,不如让它们围绕轨道旋转,以实现连续的运动。

The blue outline of a circle with ten green points along its edges. A gray arrow shows the circle's radius and assigns it to a point on the circle with a variable, r, that has a red circle around it showing its hover boundary.

我们点的初始位置(以绿色显示)将成为轨道,红色圆圈是点将经过的路径。换句话说,每个点都会围绕其初始位置旋转,沿着半径为 r 的圆圈移动。

我们所要做的就是确保两个相邻路径之间没有重叠,因此半径需要有一个最大允许值。

我不会详细介绍数学知识,但最大值等于

const r = 2*Radius*Math.sin(Math.PI/(2*N));
A blobby shape moves in a circular motion around an image of a cougar's face.
实时演示 (仅适用于 Chrome 和 Edge)

这是代码的相关部分

var r = (size.width)*Math.sin(Math.PI/(N*2));
const RADIUS = size.width/2 - r;
// ...

for(var i = 0; i < N; i++) {
  var rr = r*random();
  var xx = rr*Math.cos(B * (2 * Math.PI));
  var yy = rr*Math.sin(B * (2 * Math.PI)); 
  var x = Math.cos((i / N) * (2 * Math.PI)) * RADIUS + xx + cx;
  var y = Math.sin((i / N) * (2 * Math.PI)) * RADIUS + yy + cy;
  point[i] = [x,y];
}

我们得到半径的最大值,并将该值从主半径中减去。请记住,我们需要为我们的点留出足够的空间,因此我们需要像之前动画一样减小遮罩区域。然后,对于每个点,我们都会获得一个随机半径 rr(介于 0 和 r 之间)。然后,我们使用 xxyy 计算圆形路径内的位置。最后,我们将路径放置在其轨道周围,并获得最终位置(xy 值)。

请注意 B 的值,它通常是带有过渡的值。这一次,我们将进行从 0 到 1 的过渡,以围绕轨道进行完整的旋转。

螺旋运动

再给你一个!这是一个前两个动画的组合。

我们看到了如何围绕固定轨道移动点,以及如何将点从圆圈边缘移动到中心。我们可以将两者结合起来,让我们的点围绕轨道移动,并对轨道执行相同的操作,即将其从边缘移动到中心。

让我们在现有代码中添加一个额外的变量

for(var i = 0; i < N; i++) {
  var rr = r*random();
  var xx = rr*Math.cos(B * (2 * Math.PI));
  var yy = rr*Math.sin(B * (2 * Math.PI)); 

  var ro = RADIUS - Bo*random();
  var x = Math.cos((i / N) * (2 * Math.PI)) * ro + xx + cx;
  var y = Math.sin((i / N) * (2 * Math.PI)) * ro + yy + cy;
  point[i] = [x,y];
}

如您所见,我使用了与我们查看的第一个动画完全相同的逻辑。我们将半径使用随机值(在本例中由 Bo 控制)减少。

A blob morphs shape as it moves around an image of a cougar's face.
实时演示 (仅适用于 Chrome 和 Edge)

又一个奇特的 blob 动画!现在每个元素都有两个动画:一个动画化轨道(Bo),另一个动画化点在其圆形路径(B)中。想象一下,只需调整动画值(持续时间、缓动等),您就可以获得多少种效果!

将所有内容整合在一起

哇,我们完成了所有动画!我知道你们中有些人可能因为我们介绍的所有变体和变量而感到迷茫,但不用担心!我们现在将总结所有内容,您会发现它比您想象的要容易得多。

我还想强调,我所做的并不是所有可能动画的详尽列表。我只是处理了其中的一部分。我们可以定义更多,但本文的主要目的是了解总体结构并能够根据需要进行扩展。

让我们总结一下我们已经做了什么以及主要要点

  • 点数 (N):此变量控制 blob 形状的粒度。我们在 CSS 中定义它,然后用于定义控制点的数量。
  • 运动类型 (T):在我们查看的几乎所有动画中,我一直考虑两种动画:一种是“统一”动画,另一种是“随机”动画。我称之为我们使用 CSS 中设置的 T 变量控制的运动类型。在代码中的某个地方,我们将根据该 T 变量进行 if-else 操作。
  • 随机配置:在处理随机运动时,我们需要使用我们自己的 random() 函数,在该函数中我们可以控制种子,以便对每个元素具有相同的随机序列。种子也可以被视为一个变量,一个生成不同形状的变量。
  • 运动性质:这就是点所经过的路径。我们可以有很多变化,例如
    • 从圆圈边缘到中心
    • 单轴运动(x 轴或 y 轴)
    • 圆周运动
    • 螺旋运动
    • 以及更多其他…

与运动类型一样,运动性质也可以通过引入另一个变量来实现条件化,这里可以做的事情没有限制。我们所要做的就是找到创建另一个动画的数学公式。

  • 动画变量 (B):这是包含过渡/动画的 CSS 变量。我们通常会应用从 0 到某个值(在所有示例中都使用 V 变量定义)的过渡/动画。此变量用于表示点的 position。更新该变量会逻辑地更新 position;因此我们得到了动画。在大多数情况下,我们只需要动画化一个变量,但我们可以根据运动性质(例如螺旋形,我们使用了两个变量)拥有更多变量。
  • 形状区域:默认情况下,我们的形状覆盖整个元素区域,但我们发现有些运动需要点超出形状。这就是为什么我们必须减小该区域。我们通常通过 B 的最大值(由 V 定义)或根据运动性质定义的不同值来执行此操作。

我们的代码结构如下

var point = []; 
/* The center of the element */
const cx = size.width/2;
const cy = size.height/2;
/* We read all of the CSS variables */
const N = parseInt(properties.get('--n')); /* number of points */
const T = parseInt(properties.get('--t')); /* type of movement  */
const Na = parseInt(properties.get('--na')); /* nature of movement  */
const B = parseFloat(properties.get('--b')); /* animation variable */
const V = parseInt(properties.get('--v'));  /* max value of B */
const seed = parseInt(properties.get('--seed')); /* the seed */
// ...

/* The radius of the shape */
const RADIUS = size.width/2 - A(V,T,Na);

/* Our random() function */
let random =  function() {
  // ...
}
/* we define the position of our points */
for(var i = 0; i < N; i++) {
   var x = Fx[N,T,Na](B) + cx;
   var y = Fy[N,T,Na](B) + cy;
   point[i] = [x,y];
}

/* We draw the shape, this part is always the same */
ctx.beginPath();
// ...
ctx.closePath();
/* We fill it with a solid color */
ctx.fillStyle = '#000';
ctx.fill();

如您所见,代码并不像您想象的那么复杂。所有工作都在那些函数 FxFy 内部,它们根据 N、TNa 定义运动。我们还有函数 A,它可以减小形状的大小,以防止点在动画过程中溢出形状。

让我们检查一下 CSS

@property --b {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

img {
  -webkit-mask:paint(blob);
  --n: 20;
  --t: 0;
  --na: 1;
  --v: 50;
  --seed: 125;
  --b: 0;
  transition: --b .5s;
}
img:hover {
  --b: var(--v);
}

我认为代码不言自明。您定义变量,应用遮罩,然后使用过渡或关键帧来动画化B变量。仅此而已!

我将在本文末尾添加一个最终演示,其中将所有变体放在一起。您所要做的就是玩弄 CSS 变量。


探索 CSS Paint API 系列