最初的实现
这不是一个关于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????