关于CSS绘画API的完整教程

90 阅读4分钟

最初的实现

这不是一个关于CSS绘画API的完整教程,所以如果下面的内容不清楚,或者你想了解更多,可以查看houdini.how上的资源,以及Una的这个精彩演讲

首先,注册一个绘画工作小组:

CSS.paintWorklet.addModule(`worklet.js`);

绘画发生在一个工作小程序中,所以它不会阻塞主线程。这就是这个小程序:

class PixelGradient {
  static get inputProperties() {
    // The CSS values we're interested in:
    return ['--pixel-gradient-color', '--pixel-gradient-size'];
  }
  paint(ctx, bounds, props) {
    // TODO: We'll get to this later
  }
}

// Give our custom painting a name
// (this is how CSS will reference it):
registerPaint('pixel-gradient', PixelGradient);

还有一些CSS:

/* The end colour of the gradient */
@property --pixel-gradient-color {
  syntax: '<color>';
  initial-value: black;
  inherits: true;
}
/* The size of each block in the gradient */
@property --pixel-gradient-size {
  syntax: '<length>';
  initial-value: 8px;
  inherits: true;
}

.pixel-gradient {
  --pixel-gradient-color: #9a9a9a;
  background-color: #8a8a8a;
  /* Tell the browser to use our worklet
     for the background image */
  background-image: paint(pixel-gradient);
}

@property 告诉浏览器自定义属性的格式。这很好,因为它意味着数值可以动画化,像 ,可以指定为 、 、 等等等等--它们将被转换为像素,用于绘制工作小程序。--pixel-gradient-size em % vw

好的,现在让我们进入主要部分,即元素的绘制,输入是:

  • ctx:2d画布API的一个子集。
  • bounds:要画的区域的宽度和高度。
  • props:为我们的inputProperties 计算的值。

这里是paint 方法的主体,用来创建我们的随机梯度:

// Get styles from our input properties:
const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');

// Loop over columns
for (let x = 0; x < bounds.width; x += size) {
  // Loop over rows
  for (let y = 0; y < bounds.height; y += size) {
    // Convert our vertical position to 0-1
    const pos = (y + size / 2) / bounds.height;
    // Only draw a box if a random number
    // is less than pos
    if (Math.random() < pos) ctx.fillRect(x, y, size, size);
  }
}

所以我们已经创建了一个随机的块状渐变,但是在元素的底部出现块状渐变的几率更高。工作完成了吗?好吧,就在这里。

我喜欢paint API的一个原因是它很容易创建动画。即使是块大小的动画,我所要做的也只是在--pixel-gradient-size 上创建一个CSS过渡。总之,玩玩上面的东西,或者试着调整一下浏览器的大小。有时背景中的图案会改变,有时不会。

绘画API的优化是以确定性为前提的。同样的输入应该产生同样的输出。事实上,规范说,如果元素的大小和inputProperties ,在不同的绘画之间是相同的,浏览器可以使用我们绘画指令的缓存副本。我们用Math.random() 来打破这个假设。

我试着解释一下我在Chrome中看到的情况。

为什么在做宽度/高度/颜色/盒子大小的动画时,图案会发生变化?这些改变了元素的大小或我们的输入属性,所以元素必须重新绘制。由于我们使用Math.random() ,我们得到了一个新的随机结果。

为什么在改变文字的同时,它还能保持不变?这需要重新绘制,但由于元素的大小和输入保持不变,浏览器使用了我们模式的缓存版本。

为什么在做盒状阴影动画时它会变化?虽然盒状阴影的变化意味着元素需要重绘,但盒状阴影并不改变元素的大小,而且box-shadow ,并不是我们的inputProperties 。感觉浏览器可以在这里使用我们的模式的缓存版本,但它没有。这很好,规范并没有要求浏览器在这里使用一个缓存的副本。

为什么在做模糊动画的时候会改变两次?哈,好吧,模糊动画发生在合成器上,所以你会得到一个初始的重绘,把元素提升到它自己的层上。但是,在动画过程中,它只是模糊了缓存的结果。然后,一旦动画完成,它就会删除该层,并将该元素作为页面的一个普通部分来绘制。浏览器可以在这些重绘中使用缓存的结果,但它没有这样做。

上述的行为方式可能会因浏览器的不同而不同,乘以版本,乘以显示/图形硬件。

我向我的同事解释了这一点,他们说:"那又怎样?这很有趣!"。不要再试图压制乐趣了,杰克"。好吧,我要告诉你,你可以用绘画的确定性和流畅的动画来创造伪随机效果*,同时又能获得乐趣*。也许吧。

制造随机,而不是随机

计算机不能真正做到随机。相反,他们采取一些状态,并在上面做一些热的数学运算来创造一个数字。然后,他们修改这个状态,使下一个数字看起来与之前的数字无关。但事实是它们是100%相关的。

如果你从相同的初始状态开始,你会得到相同的随机数字序列。这就是我们想要的--看起来是随机的,但它是100%可重复的。好消息是这就是Math.random() ,坏消息是它不让我们设置初始状态。

取而代之的是,让我们使用另一个可以让我们设置初始状态实现:

function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    var t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Using an initial state of 123456:
const rand = mulberry32(123456);
rand(); // 0.38233304349705577
rand(); // 0.7972629074938595
rand(); // 0.9965302373748273

这个gist有一个很棒的随机数生成器的集合。我选择了mulberry32 ,因为它很简单,而且对视觉随机性来说足够好。我想强调的是,我只推荐这个用于视觉随机性。如果你要实现自己的密码学,这是我唯一有资格给出的建议:不要

我也不是说mulberry32 是坏的。我只是说,如果你因为受到这篇文章的影响而导致你的屁股被偷,不要来向我哭诉。

无论如何,这里是mulberry32 的操作。

让我们把mulberry32 ,在这里工作...

使绘画具有确定性

我们将为种子添加另一个自定义属性:

@property --pixel-gradient-seed {
  syntax: '<number>';
  initial-value: 1;
  inherits: true;
}

...我们也将把它添加到我们的inputProperties 。然后,我们可以修改我们的绘画代码:

const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');

// Get the seed…
const seed = props.get('--pixel-gradient-seed').value;
// …and create a random number generator:
const rand = mulberry32(seed);

for (let x = 0; x < bounds.width; x += size) {
  for (let y = 0; y < bounds.height; y += size) {
    const pos = (y + size / 2) / bounds.height;
    // …and use it rather than Math.random()
    if (rand() < pos) ctx.fillRect(x, y, size, size);
  }
}

现在制作宽度、颜色、阴影和模糊的动画不会再出现故障了!但是,我们不能说动画的高度和块的大小也是如此。让我们来解决这个问题!

太有趣了,对吗?

处理行和列

现在,我们为每个区块调用rand() 。看一下这个:

假设每个方块是一个区块,数字代表调用rand() 的次数。当你对宽度进行动画处理时,数字保持在同一位置,但当你对高度进行动画处理时,它们就会移动(除了第一列之外)。所以,随着高度的变化,我们的像素的随机性也会发生变化,这使得它看起来像噪音的动画。相反,我们想要的东西更像这样。

...其中我们的随机值有两个维度。值得庆幸的是,我们已经有两个维度可以玩了,即调用rand() 的次数,以及种子的数量。

你有过这么大的乐趣吗?

在两个维度上被随机预测

这一次,我们将为每一列重新输入我们的随机函数。

const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');

let seed = props.get('--pixel-gradient-seed').value;

for (let x = 0; x < bounds.width; x += size) {
  // Create a new rand() for this column:
  const rand = mulberry32(seed);
  // Increment the seed for next time:
  seed++;

  for (let y = 0; y < bounds.height; y += size) {
    const pos = (y + size / 2) / bounds.height;
    if (rand() < pos) ctx.fillRect(x, y, size, size);
  }
}

现在,高度和块大小以更自然的方式动画化了!但还有最后一件事要解决。通过为每一列的种子递增1,我们在我们的模式中引入了视觉可预测性。你可以看到这一点,如果你 "递增种子"--它不是产生一个新的随机图案,而是沿着图案移动(直到它超过了JavaScript的最大安全整数,在这一点上会发生怪异的事情)。与其将种子递增1,不如以某种方式改变它,让它感觉是随机的,但却是100%确定的。哦,等等,这就是我们的rand() 函数所做的!

事实上,让我们创建一个可以为多个维度 "分叉 "的mulberry32 版本:

function randomGenerator(seed) {
  let state = seed;

  const next = () => {
    state |= 0;
    state = (state + 0x6d2b79f5) | 0;
    var t = Math.imul(state ^ (state >>> 15), 1 | state);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };

  return {
    next,
    // Instead of incrementing, set the seed
    // to a 'random' 32 bit value:
    fork: () => randomGenerator(next() * 2 ** 32),
  };
}

我们使用一个随机的32位值,因为这就是mulberry32 工作的状态量。然后我们的绘画方法就可以使用它:

const size = props.get('--pixel-gradient-size').value;
ctx.fillStyle = props.get('--pixel-gradient-color');
const seed = props.get('--pixel-gradient-seed').value;
// Create our initial random generator:
const randomXs = randomGenerator(seed);

for (let x = 0; x < bounds.width; x += size) {
  // Then fork it for each column:
  const randomYs = randomXs.fork();

  for (let y = 0; y < bounds.height; y += size) {
    const pos = (y + size / 2) / bounds.height;
    if (randomYs.next() < pos) ctx.fillRect(x, y, size, size);
  }
}

现在改变种子会产生一个全新的模式。

让乐趣回归

好吧,我承认动画噪音效果很酷,但这是我们无法控制的。有些人对闪烁的图像和随机变化的视觉效果反应很差,所以这绝对我们想要控制的东西。

然而,现在我们已经把--pixel-gradient-seed 定义为一个数字,我们可以把它做成动画,重新创造出动画噪音效果:

@keyframes animate-pixel-gradient-seed {
  from {
    --pixel-gradient-seed: 0;
  }
  to {
    --pixel-gradient-seed: 4294967295;
  }
}

.animated-pixel-gradient {
  background-image: paint(pixel-gradient);
  animation: 60s linear infinite animate-pixel-gradient-seed;
}

/* Be nice to users who don't want
   that kind of animation: */
@media (prefers-reduced-motion: reduce) {
  .animated-pixel-gradient {
    animation: none;
  }
}

这就是它。

现在我们可以选择在我们想要的时候对噪音进行动画处理,但在其他时候保持稳定。

但是,随机放置的情况如何?

有些CSS彩绘效果是用物体的随机摆放而不是随机像素来工作的,比如彩纸/烟花效果。你也可以在那里使用类似的原则。与其在元素周围随机放置物品,不如将你的元素分割成一个网格。

// We'll split the element up
// into 300x300 cells:
const gridSize = 300;
const density = props.get('--confetti-density').value;
const seed = props.get('--confetti-seed').value;
// Create our initial random generator:
const randomXs = randomGenerator(seed);

for (let x = 0; x < bounds.width; x += gridSize) {
  // Fork it for each column:
  const randomYs = randomXs.fork();

  for (let y = 0; y < bounds.height; y += gridSize) {
    // Fork it again for each cell:
    const randomItems = randomYs.fork();

    for (let _ = 0; _ < density; _++) {
      const confettiX = randomItems.next() * gridSize + x;
      const confettiY = randomItems.next() * gridSize + y;
      // TODO: Draw confetti at
      // confettiX, confettiY.
    }
  }
}

这一次我们有3个维度的随机性--行、列和密度。使用单元格的另一个好处是,无论元素有多大,彩纸的密度都会一致。

现在,密度可以改变/动画化,而不需要每次都创建一个全新的图案!看,这很有趣,对吗?对吗?RIGHT????