用CSS绘画API:制作Blob动画

105 阅读8分钟

在碎片化效果之后,我将处理另一个有趣的动画:blob!我们都同意,这样的效果很难用CSS来实现,所以我们一般会用SVG来制作那些粘稠的形状。但是现在有了强大的Paint API,使用CSS不仅是可能的,而且一旦浏览器支持,甚至可能是一种更好的方法。

这就是我们正在做的东西。目前只是支持Chrome和Edge,所以当我们进行时,请在其中一个浏览器上查看。

现场演示(仅限Chrome和Edge)

构建blob

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

CodePen嵌入回退

当谈论一个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 */

现在我们要做的就是更新我们的控制点的位置来创建博客形状。让我们通过添加以下内容来尝试一个点。

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;

第三个点最接近我们的圆心(大约50px),我们的立方贝塞尔曲线完美地跟随移动,保持一个弯曲的形状。

让我们对所有的点做同样的事情。我们可以用同样的总体思路,改变这些现有的线条。

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像素之间的随机值所偏移,使每个点以稍微不同的数量靠近中心。这样我们就得到了我们的圆球形状!

现在,我们使用CSS Paint API将该形状作为一个图像的遮罩。因为我们要处理的是一个圆球状的形状,所以我们可以考虑用方形元素(高度等于宽度)来代替,其中的半径等于宽度或高度的一半。

在这里,我们去使用一个CSS变量(N)来控制点的数量。

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

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

让我们把它做成动画吧!

画一个博客是好的,但把它做成动画就更好了!毕竟,为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.

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

通过调整CSS变量,我们可以轻松实现不同的blobby变化。我们定义了点的数量和B 变量的最终值。

现在让我们用一些随机的东西试试。我们不要用一个固定的值来移动我们的点,而是让这个值随机地把它们全部移动。我们之前用的是这个。

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

让我们把它改成这样。

var r = RADIUS - B*random();

...其中random() 给我们一个范围为[0 1] 的值。换句话说,每个点都被一个0到B 之间的随机值所移动。这就是我们得到的结果。

看到了吗?我们用同样的代码结构得到了另一个很酷的动画。我们只改变了一条指令。我们可以把这个指令变成一个变量,这样我们就可以在不改变JavaScript的情况下决定是使用统一配置还是随机配置。我们引入了另一个变量,T ,它的行为像一个布尔值。

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

CodePen Embed Fallback

我们有两个动画,由于T 这个变量,我们可以决定使用哪一个。我们可以用N 控制点的数量,用变量V 控制距离。是的,有很多变量,但不要担心,我们会在最后总结所有的东西。

那个random() 函数是做什么的?

这是我在前一篇文章中使用的同一个函数。我们在那里看到,我们不能依赖默认的内置函数,因为我们需要一个随机函数,我们能够控制种子,以确保我们总是得到相同的随机值序列。所以种子值也是我们可以控制的另一个变量,以获得不同的blob形状。去手动改变这个值,看看结果如何。

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

现场演示(仅限Chrome和Edge)

该演示包括另一个使用我在之前文章中详述的抛物线的例子。

控制点的移动

在我们到目前为止所创建的所有Blobs中,我们都考虑了我们的点的相同运动。无论我们使用的是统一配置还是随机配置,我们总是按照一条线将点从圆的边缘移到圆的中心。

现在让我们来看看我们如何控制这种运动,以便获得更多的动画。这个逻辑背后的想法很简单:我们以不同的方式移动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 计算出圆形路径内的位置。最后,我们将路径放置在它的轨道上,得到最终的位置(x,y 的值)。

请注意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 )。这个变量是用来表达我们的点的位置的。更新这个变量可以逻辑地更新位置;因此我们可以得到动画。在大多数情况下,我们只需要对一个变量进行动画处理,但根据运动的性质,我们可以有更多的变量(像螺旋形的运动,我们使用了两个变量)。
  • **形状区域。**默认情况下,我们的形状覆盖了整个元素区域,但我们看到,有些运动需要点到形状之外。这就是为什么我们必须减少面积。我们通常通过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 变量进行动画。这就是全部