浏览器长期以来一直是艺术和设计的媒介。 从 Lynn Fisher 充满活力的 A Single Div 作品到 Diana Smith 令人惊叹的 CSS 绘图,多年来,极具创造力和高超技能的开发人员不断将 Web 技术推向极限,并创造出创新且令人鼓舞的视觉效果。
然而,CSS 从未真正拥有专门用于……嗯,仅仅是绘制东西的 API! 正如上面才华横溢的人们所展示的那样,它当然可以渲染大多数东西,但并不总是那么容易,并且对于生产网站/应用程序来说并不总是实用。
不过,最近 CSS 获得了一套令人兴奋的新 API,称为 Houdini,其中之一——Paint API——是专门设计用于渲染 2D 图形的。 对于我们这些 Web 开发人员来说,这令人难以置信地兴奋。 我们第一次拥有了一个 CSS 部分,其唯一目的是以编程方式创建图像。 通往神秘新世界的大门已经完全敞开!
在本教程中,我们将使用 Paint API 创建三个(希望是!)美丽、生成式的图案,这些图案可用于为各种网站/应用程序添加一抹独特的风格。
朋友们,准备好魔法书/文本编辑器,让我们施展魔法吧!
目标受众
本教程非常适合那些熟悉编写 HTML、CSS 和 JavaScript 的用户。 对生成式艺术和 Paint API/HTML canvas 的一些了解会很有帮助,但不是必需的。 我们将在开始之前快速概述一下。 顺便说一下……
开始之前
要全面了解 Paint API 和生成式艺术/设计,我建议您访问 本系列的第一个条目。 如果您是这两个主题的新手,这将是一个很好的起点。 但是,如果您不想阅读另一篇文章,以下是在继续之前需要熟悉的一些关键概念。
如果您已经熟悉 CSS Paint API 和生成式艺术/设计,可以 跳到下一节。
什么是生成式艺术/设计?
生成式艺术/设计是指任何包含偶然因素的作品。 我们定义一些规则,并允许随机性来引导我们获得结果。 例如,一个规则可能是“如果随机数大于 50,则渲染一个红色正方形,如果小于 50,则渲染一个蓝色正方形”,在浏览器中,随机性的来源可以是 Math.random()
。
通过采用生成式方法来创建图案,我们可以生成单个想法的近乎无限的变化——这既是对创作过程的鼓舞人心的补充,也是一个让用户惊喜的绝佳机会。 我们可以为用户显示一些特别且独特的东西,而不是每次他们访问页面时都显示相同的图像!
什么是 CSS Paint API?
Paint API 使我们能够低级别地访问 CSS 渲染。 通过“绘图工作线程”(具有特殊 paint()
函数的 JavaScript 类),它允许我们使用与 HTML canvas 几乎相同的语法动态创建图像。 工作线程可以在 CSS 预期图像的任何位置渲染图像。 例如
.worklet-canvas {
background-image: paint(workletName);
}
Paint API 工作线程快速、响应迅速,并且与现有的基于 CSS 的设计系统配合得非常好。 简而言之,它们是迄今为止最酷的东西。 它们现在唯一缺少的是广泛的浏览器支持。 以下是一个表格
此浏览器支持数据来自 Caniuse,其中包含更多详细信息。 数字表示浏览器在该版本及更高版本中支持该功能。
桌面
Chrome | Firefox | IE | Edge | Safari |
---|---|---|---|---|
65 | 不支持 | 不支持 | 79 | 不支持 |
移动/平板电脑
Android Chrome | Android Firefox | Android | iOS Safari |
---|---|---|---|
127 | 不支持 | 127 | 不支持 |
支持情况有点少! 不过没关系。 由于 Paint API 几乎天生就是装饰性的,因此如果可用,我们可以将其用作渐进增强,如果不可用,则提供一个简单可靠的回退方案。
我们将要制作什么
在本教程中,我们将学习如何创建三种独特的生成式图案。 这些图案非常简单,但将成为进一步实验的绝佳跳板。 以下是它们的光辉形象!
本教程中的演示目前仅在 Chrome 和 Edge 中有效。
“微小斑点”
“包豪斯”
“Voronoi 弧线”
在继续之前,请花点时间浏览上面的示例。 尝试更改自定义属性并调整浏览器窗口大小——观察图案如何反应。 您是否可以在不查看 JavaScript 代码的情况下猜出它们的工作原理?
设置
为了节省时间并避免任何自定义构建过程,我们将在本教程中完全在 CodePen 中进行操作。 我甚至创建了一个“启动笔”,我们可以将其用作每个图案的基础!
我知道,它看起来并不起眼…… 至少现在是这样。
在启动笔中,我们使用 JavaScript 部分来编写工作线程本身。 然后,在 HTML 部分,我们使用内部 <script>
标签直接加载 JavaScript。 由于 Paint API 工作线程是特殊的 工作线程(在单独的浏览器线程上运行的代码),因此它们的来源必须1 存在于独立的 .js
文件中。
让我们分解此处代码的关键部分。
如果您之前编写过 Paint API 工作线程,并且熟悉 CodePen,可以 跳到下一节。
定义工作线程类
首先:让我们查看 JavaScript 选项卡。 在这里,我们使用一个简单的 paint()
函数定义了一个工作线程类
class Worklet {
paint(ctx, geometry, props) {
const { width, height } = geometry;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
}
}
我喜欢将 worklet 的 `paint()` 函数视为一个回调函数。当 worklet 的目标元素更新(尺寸变化、自定义属性修改)时,它会重新运行。worklet 的 `paint()` 函数在执行时会自动传递一些参数。在本教程中,我们对前三个参数感兴趣。
ctx
— 一个 2D 绘图上下文,与 HTML canvas 的非常相似。geometry
— 一个包含 worklet 的目标元素的宽度/高度尺寸的对象。props
— 一个 CSS 自定义属性的数组,我们可以“监视”它们的更改并在发生更改时重新渲染。这些是将值传递给 paint worklet 的一种好方法。
我们的入门 worklet 渲染了一个覆盖其目标元素整个宽度/高度的黑色正方形。我们将为每个示例完全重写此 `paint()` 函数,但定义一些内容来检查功能是否正常运行总是一件好事。
注册 worklet
定义 worklet 类后,需要在使用它之前进行注册。为此,我们在 worklet 文件本身中调用 `registerPaint`
if (typeof registerPaint !== "undefined") {
registerPaint("workletName", Worklet);
}
然后在我们的“主”JavaScript/HTML 中调用 `CSS.paintWorklet.addModule()`
<script id="register-worklet">
if (CSS.paintWorklet) {
CSS.paintWorklet.addModule('https://codepen.io/georgedoescode/pen/bGrMXxm.js');
}
</script>
我们在这里检查 `registerPaint` 是否已定义后再运行它,因为我们的笔的 JavaScript 将始终在主浏览器线程上运行一次 - `registerPaint` 只有在 JavaScript 文件使用 `CSS.paintWorklet.addModule(...)` 加载到 worklet 中后才能使用。
应用 worklet
注册后,我们可以使用我们的 worklet 为任何期望图像的 CSS 属性生成图像。在本教程中,我们将重点关注 `background-image`
.worklet-canvas {
background-image: paint(workletName);
}
包导入
您可能会注意到入门笔的 worklet 文件顶部有一些悬挂的包导入。
import random from "https://cdn.skypack.dev/random";
import seedrandom from "https://cdn.skypack.dev/seedrandom";
你能猜出它们是什么吗?
随机数生成器!
在本教程中创建的所有三种模式都严重依赖于随机性。但是,Paint API worklet 应该(几乎)始终是确定性的。给定相同的输入属性和尺寸,worklet 的 `paint()` 函数应该始终渲染相同的内容。
为什么?
- Paint API 可能会使用 worklet 的 `paint()` 输出的缓存版本以提高性能。在 worklet 中引入不可预测的元素会使这变得不可能!
- 每当应用它的元素的尺寸发生变化时,worklet 的 `paint()` 函数就会重新运行。当与“纯”随机性结合使用时,这会导致内容出现明显的闪烁 - 对某些人来说,这是一个潜在的无障碍问题。
对我们来说,所有这些都使 `Math.random()` 变得毫无用处,因为它完全不可预测。作为替代方案,我们正在引入 random(一个用于处理随机数的优秀库)和 seedrandom(一个伪随机数生成器,用作其基本算法)。
举个简单的例子,这是一个使用伪随机数生成器的“随机圆圈”worklet。
这是一个使用 `Math.random()` 的类似 worklet。警告:调整元素大小会导致图像闪烁。
上述两种模式的右下角都有一个小的“调整大小”手柄。尝试调整两个元素的大小。注意到区别了吗?
设置每个模式
在开始以下每个模式之前,请导航到入门笔并在页脚中点击“Fork”按钮。Fork 笔 会在您点击按钮的那一刻创建原始笔的副本。从这一点开始,它属于您,您可以随心所欲地使用它。
分叉入门笔后,还有一个**关键的额外步骤**需要完成。传递给 `CSS.paintWorklet.addModule` 的 URL 必须更新为指向新分叉的 JavaScript 文件。要查找分叉的 JavaScript 的路径,请查看浏览器中显示的 URL。您需要获取分叉的 URL(删除所有查询参数),然后附加 `.js` - 类似这样

很好。就是这样!获得 JavaScript 的 URL 后,请确保在此处更新它。
<script id="register-worklet">
if (CSS.paintWorklet) {
// ⚠️ hey friend! update the URL below each time you fork this pen! ⚠️
CSS.paintWorklet.addModule('https://codepen.io/georgedoescode/pen/QWMVdPG.js');
}
</script>
使用此设置时,您可能偶尔需要手动刷新笔才能查看更改。为此,请按 CMD/CTRL + Shift + 7。
模式 #1(微小斑点)
好的,我们准备制作我们的第一个模式。分叉入门笔,更新 `.js` 文件引用,并准备进行一些生成性乐趣!
作为快速提醒,这是完成的模式。
更新 worklet 的名称
再次强调,首先要做的第一件事:让我们更新入门 worklet 的名称和相关引用。
class TinySpecksPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("tinySpecksPattern", TinySpecksPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(tinySpecksPattern);
}
定义 worklet 的输入属性
我们的“微小斑点”worklet 将接受以下输入属性。
--pattern-seed
— 伪随机数生成器的种子值。--pattern-colors
— 每个斑点的可用颜色。--pattern-speck-count
— worklet 应该渲染多少个单独的斑点。--pattern-speck-min-size
— 每个斑点的最小尺寸。--pattern-speck-max-size
— 每个斑点的最大尺寸。
作为我们的下一步,让我们定义我们的 worklet 可以接收的 `inputProperties`。为此,我们可以向我们的 `TinySpecksPattern` 类添加一个 getter。
class TinySpecksPattern {
static get inputProperties() {
return [
"--pattern-seed",
"--pattern-colors",
"--pattern-speck-count",
"--pattern-speck-min-size",
"--pattern-speck-max-size"
];
}
// ...
}
以及我们 CSS 中的一些自定义属性定义。
@property --pattern-seed {
syntax: "<number>";
initial-value: 1000;
inherits: true;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #161511, #dd6d45, #f2f2f2;
inherits: true;
}
@property --pattern-speck-count {
syntax: "<number>";
initial-value: 3000;
inherits: true;
}
@property --pattern-speck-min-size {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --pattern-speck-max-size {
syntax: "<number>";
initial-value: 3;
inherits: true;
}
我们在这里使用 属性和值 API(Houdini 家族的另一个成员)来定义我们的自定义属性。这样做为我们提供了两个宝贵的益处。首先,我们可以为 worklet 期望的输入属性定义合理的默认值。开发者体验的一抹美味的点缀!其次,通过为每个自定义属性包含一个 `syntax` 定义,我们的 worklet 可以智能地解释它们。
例如,我们为 `--pattern-colors` 定义语法 `<color>#`。反过来,这允许我们将以任何有效的 CSS 颜色格式分隔的颜色数组传递给 worklet。当我们的 worklet 接收这些值时,它们已转换为 RGB 并放置在一个整洁的小数组中。如果没有 `syntax` 定义,worklet 会将所有 `props` 解释为简单的字符串。
与 Paint API 一样,属性和值 API 也具有 有限的浏览器支持。
`paint()` 函数
太棒了!这是有趣的部分。我们已经创建了“微小斑点”worklet 类,注册了它,并定义了它可以接收哪些输入属性。现在,让我们让它*做点什么*!
作为第一步,让我们清除入门笔的 `paint()` 函数,只保留 `width` 和 `height` 定义。
paint(ctx, geometry, props) {
const { width, height } = geometry;
}
接下来,让我们将输入属性存储在一些变量中。
const seed = props.get("--pattern-seed").value;
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
const count = props.get("--pattern-speck-count").value;
const minSize = props.get("--pattern-speck-min-size").value;
const maxSize = props.get("--pattern-speck-max-size").value;
接下来,我们应该初始化我们的伪随机数生成器。
random.use(seedrandom(seed));
啊哈,可预测的随机性!我们每次 `paint()` 运行时都使用相同的 `seed` 值重新播种 `seedrandom`,从而在渲染过程中产生一致的随机数流。
最后,让我们绘制我们的斑点!
首先,我们创建一个 for 循环,迭代 `count` 次。在此循环的每次迭代中,我们都会创建一个单独的斑点。
for (let i = 0; i < count; i++) {
}
作为 for 循环中的第一个操作,我们为斑点定义一个 `x` 和 `y` 位置。在 worklet 的目标元素的宽度/高度之间是完美的。
const x = random.float(0, width);
const y = random.float(0, height);
接下来,我们选择一个随机大小(用于 `radius`)。
const radius = random.float(minSize, maxSize);
所以,我们为斑点定义了一个位置和大小。让我们从我们的 `colors` 中选择一个随机颜色来填充它。
ctx.fillStyle = colors[random.int(0, colors.length - 1)];
好了。我们都准备好了。让我们使用 `ctx` 来渲染一些东西!
在这一点上,我们首先需要做的是save()
绘图上下文的状态。为什么?我们希望旋转每个斑点,但是当使用像这样的 2D 绘图上下文时,我们无法旋转单个项目。要旋转一个对象,我们必须旋转整个绘图空间。如果我们不save()
和restore()
上下文,每次迭代中的旋转/平移都会累积,导致我们的画布变得非常混乱(或为空)!
ctx.save();
现在我们已经保存了绘图上下文的状态,我们可以translate
到斑点的中心点(由我们的x
/y
变量定义)并应用旋转。在旋转之前将对象平移到其中心点可确保对象围绕其中心轴旋转
ctx.translate(x, y);
ctx.rotate(((random.float(0, 360) * 180) / Math.PI) * 2);
ctx.translate(-x, -y);
应用旋转后,我们将平移回绘图空间的左上角。
我们在这里选择 0 到 360(度)之间的随机值,然后将其转换为弧度(ctx
理解的旋转格式)。
太棒了!最后,让我们渲染一个椭圆——这就是定义我们斑点的形状
ctx.beginPath();
ctx.ellipse(x, y, radius, radius / 2, 0, Math.PI * 2, 0);
ctx.fill();
这是一个简单的笔,更近距离地展示了我们随机斑点的形式
完美。现在,我们只需要恢复绘图上下文即可
ctx.restore();
就是这样!我们的第一个图案完成了。我们也为我们的 worklet 画布应用一个background-color
以完成效果
.worklet-canvas {
background-color: #90c3a5;
background-image: paint(tinySpecksPattern);
}
下一步
从这里开始,尝试更改斑点的颜色、形状和分布。您可以将此图案扩展到数百个方向!这是一个使用小三角形而不是椭圆的示例
继续前进!
图案 #2(包豪斯)
干得好!一个图案完成了。继续下一个。再次,fork 初始 Pen 并更新 worklet 的 JavaScript 引用以开始。
作为快速复习,这是我们正在努力完成的最终图案
更新 worklet 的名称
就像我们上次做的那样,让我们先更新 worklet 的名称和相关引用
class BauhausPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("bauhausPattern", BauhausPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(bauhausPattern);
}
很好。
定义 worklet 的输入属性
我们的“包豪斯图案”worklet 期待以下输入属性
--pattern-seed
— 伪随机数生成器的种子值。--pattern-colors
— 图案中每个形状可用的颜色--pattern-size
— 用于定义方形图案区域的宽度和高度的值--pattern-detail
— 将方形图案划分为多少列/行
让我们将这些输入属性添加到我们的 worklet 中
class BahausPattern {
static get inputProperties() {
return [
"--pattern-seed",
"--pattern-colors",
"--pattern-size",
"--pattern-detail"
];
}
// ...
}
…并在我们的 CSS 中定义它们,同样,使用属性和值 API
@property --pattern-seed {
syntax: "<number>";
initial-value: 1000;
inherits: true;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #2d58b5, #f43914, #f9c50e, #ffecdc;
inherits: true;
}
@property --pattern-size {
syntax: "<number>";
initial-value: 1024;
inherits: true;
}
@property --pattern-detail {
syntax: "<number>";
initial-value: 12;
inherits: true;
}
优秀。让我们开始绘制吧!
`paint()` 函数
同样,让我们清除启动 worklet 的 paint 函数,只保留width
和height
定义
paint(ctx, geometry, props) {
const { width, height } = geometry;
}
接下来,让我们将输入属性存储在一些变量中。
const patternSize = props.get("--pattern-size").value;
const patternDetail = props.get("--pattern-detail").value;
const seed = props.get("--pattern-seed").value;
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
现在,我们可以像以前一样为我们的伪随机数生成器播种
random.use(seedrandom(seed));
太棒了!您可能已经注意到,Paint API worklet 的设置总是有些相似。这不是最令人兴奋的过程,但这是一个反思 worklet 架构以及其他开发人员如何使用它的绝佳机会。
因此,使用此 worklet,我们创建了一个填充有形状的固定尺寸方形图案。然后,此固定尺寸的图案会放大或缩小以覆盖 worklet 的目标元素。可以将此行为视为 CSS 中的background-size: cover
!
这是一个图表

为了在我们的代码中实现此行为,让我们向我们的 worklet 类添加一个scaleContext
函数
scaleCtx(ctx, width, height, elementWidth, elementHeight) {
const ratio = Math.max(elementWidth / width, elementHeight / height);
const centerShiftX = (elementWidth - width * ratio) / 2;
const centerShiftY = (elementHeight - height * ratio) / 2;
ctx.setTransform(ratio, 0, 0, ratio, centerShiftX, centerShiftY);
}
并在我们的paint()
函数中调用它
this.scaleCtx(ctx, patternSize, patternSize, width, height);
现在,我们可以使用一组固定尺寸进行工作,并让我们的 worklet 的绘图上下文自动为我们缩放所有内容——对于许多用例来说,这是一个方便的功能。
接下来,我们将创建一个二维单元格网格。为此,我们定义了一个cellSize
变量(图案区域的大小除以我们想要的列/行数)
const cellSize = patternSize / patternDetail;
然后,我们可以使用cellSize
变量“遍历”网格,创建等间距、等大小的单元格以添加随机形状
for (let x = 0; x < patternSize; x += cellSize) {
for (let y = 0; y < patternSize; y += cellSize) {
}
}
在第二个嵌套循环中,我们可以开始渲染内容!
首先,让我们为当前形状选择一个随机颜色
const color = colors[random.int(0, colors.length - 1)];
ctx.fillStyle = color;
接下来,让我们存储对当前单元格中心x
和y
位置的引用
const cx = x + cellSize / 2;
const cy = y + cellSize / 2;
在此 worklet 中,我们将所有形状相对于其中心点进行定位。既然我们在这里,让我们向我们的 worklet 文件添加一些实用程序函数,以帮助我们快速渲染居中的形状对象。这些可以位于Worklet
类之外
function circle(ctx, cx, cy, radius) {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.closePath();
}
function arc(ctx, cx, cy, radius) {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 1);
ctx.closePath();
}
function rectangle(ctx, cx, cy, size) {
ctx.beginPath();
ctx.rect(cx - size / 2, cy - size / 2, size, size);
ctx.closePath();
}
function triangle(ctx, cx, cy, size) {
const originX = cx - size / 2;
const originY = cy - size / 2;
ctx.beginPath();
ctx.moveTo(originX, originY);
ctx.lineTo(originX + size, originY + size);
ctx.lineTo(originX, originY + size);
ctx.closePath();
}
我不会在这里详细介绍,但这是一个图表,可视化了这些函数的工作原理

如果您在任何本教程中的 worklet 的图形渲染部分遇到问题,请查看MDN 上有关 HTML 画布的文档。语法/用法与 Paint API worklet 中提供的 2D 图形上下文几乎相同。
酷!让我们回到我们的paint()
函数的嵌套循环。接下来我们需要做的是选择要渲染的形状。为此,我们可以从一个可能性数组中选择一个随机字符串
const shapeChoice = ["circle", "arc", "rectangle", "triangle"][
random.int(0, 3)
];
我们也可以以非常类似的方式选择一个随机旋转量
const rotationDegrees = [0, 90, 180][random.int(0, 2)];
完美。我们准备好了渲染!
首先,让我们保存绘图上下文的状态,就像在之前的 worklet 中一样
ctx.save();
接下来,我们可以translate
到当前单元格的中心点,并使用我们刚刚选择的随机值旋转画布
ctx.translate(cx, cy);
ctx.rotate((rotationDegrees * Math.PI) / 180);
ctx.translate(-cx, -cy);
现在我们可以渲染形状本身了!让我们将我们的shapeChoice
变量传递给一个switch
语句,并使用它来决定要运行哪个形状渲染函数
switch (shapeChoice) {
case "circle":
circle(ctx, cx, cy, cellSize / 2);
break;
case "arc":
arc(ctx, cx, cy, cellSize / 2);
break;
case "rectangle":
rectangle(ctx, cx, cy, cellSize);
break;
case "triangle":
triangle(ctx, cx, cy, cellSize);
break;
}
ctx.fill();
最后,我们需要做的就是restore()
我们的绘图上下文,为下一个形状做好准备
ctx.restore();
这样,我们的包豪斯网格 worklet 就完成了!
下一步
您可以将此 worklet 扩展到很多方向。您如何进一步对其进行参数化?您可以为特定形状/颜色添加“偏差”吗?您可以添加更多形状类型吗?
始终进行实验——跟随我们一起创建的示例是一个很好的开始,但最好的学习方法是制作自己的东西!如果您缺乏灵感,请在Dribbble上查看一些图案,参考您喜欢的艺术家、周围的建筑、自然,等等!
作为一个简单的示例,这是同一个 worklet,使用了完全不同的配色方案
图案 #3(Voronoi 弧线)
到目前为止,我们已经创建了一个混乱的图案和一个严格对齐到网格的图案。对于我们的最后一个示例,让我们构建一个介于两者之间的图案。
作为最后的提醒,这是最终的图案
在我们开始编写任何代码之前,让我们看看此 worklet…是如何工作的。
简要介绍 Voronoi 镶嵌
顾名思义,此 worklet 使用称为Voronoi 镶嵌 的方法来计算其布局。简而言之,Voronoi 镶嵌(或图)是一种将空间划分为不重叠的多边形的方法。
我们将一系列点添加到二维空间中。然后,对于每个点,计算一个仅包含它且不包含任何其他点的多边形。计算完成后,这些多边形可用作一种“网格”来定位任何内容。
这是一个动画示例
基于 Voronoi 的布局最吸引人的一点是,它们以一种非常不寻常的方式响应。当 Voronoi 镶嵌中的点四处移动时,多边形会自动重新排列自身以填充空间!
尝试调整下面元素的大小,看看会发生什么!
很酷,对吧?
如果您想了解更多关于 Voronoi 的所有内容,我有一篇文章 进行了深入探讨。但是,就目前而言,这正是我们所需要的。
更新 worklet 的名称
好了,朋友们,我们知道该怎么做了。让我们 fork 初始 Pen,更新 JavaScript 导入,并更改 worklet 的名称和引用
class VoronoiPattern {
// ...
}
if (typeof registerPaint !== "undefined") {
registerPaint("voronoiPattern", VoronoiPattern);
}
.worklet-canvas {
/* ... */
background-image: paint(voronoiPattern);
}
定义 worklet 的输入属性
我们的VoronoiPattern
worklet 期待以下输入属性
--pattern-seed
— 伪随机数生成器的种子值。--pattern-colors
— 图案中每个弧线/圆圈可用的颜色--pattern-background
— 图案的背景颜色
让我们将这些输入属性添加到我们的 worklet 中
class VoronoiPattern {
static get inputProperties() {
return ["--pattern-seed", "--pattern-colors", "--pattern-background"];
}
// ...
}
…并在我们的 CSS 中注册它们
@property --pattern-seed {
syntax: "<number>";
initial-value: 123456;
inherits: true;
}
@property --pattern-background {
syntax: "<color>";
inherits: false;
initial-value: #141b3d;
}
@property --pattern-colors {
syntax: "<color>#";
initial-value: #e9edeb, #66aac6, #e63890;
inherits: true;
}
不错!我们准备就绪了。穿上工作服,朋友们——让我们开始绘制吧。
`paint()` 函数
首先,让我们清除启动 worklet 的paint()
函数,只保留width
和height
定义。然后,我们可以使用我们的输入属性创建一些变量,并为我们的伪随机数生成器播种。就像我们之前的示例一样
paint(ctx, geometry, props) {
const { width, height } = geometry;
const seed = props.get("--pattern-seed").value;
const background = props.get("--pattern-background").toString();
const colors = props.getAll("--pattern-colors").map((c) => c.toString());
random.use(seedrandom(seed));
}
在我们做任何其他事情之前,让我们先绘制一个快速的背景颜色
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
接下来,让我们导入一个辅助函数,它将允许我们快速创建一个 Voronoi 镶嵌
import { createVoronoiTessellation } from "https://cdn.skypack.dev/@georgedoescode/generative-utils";
此函数本质上是d3-delaunay的一个包装器,并且是我 generative-utils 存储库的一部分。您可以在 GitHub 上查看源代码。“经典”数据结构/算法(如 Voronoi 镶嵌)无需重新发明轮子——除非您想这样做!
现在我们有了createVoronoiTessellation
函数,让我们将其添加到paint()
中
const { cells } = createVoronoiTessellation({
width,
height,
points: [...Array(24)].map(() => ({
x: random.float(0, width),
y: random.float(0, height)
}))
});
在这里,我们创建了一个 Voronoi 镶嵌,其宽度和高度与 worklet 的目标元素相同,并具有 24 个控制点。
太棒了。是时候渲染我们的形状了!由于前面两个示例,很多代码我们应该都很熟悉。
首先,我们遍历镶嵌中的每个单元格
cells.forEach((cell) => {
});
对于每个单元格,我们首先要选择一种颜色
ctx.fillStyle = colors[random.int(0, colors.length - 1)];
接下来,我们存储对单元格中心 x 和 y 值的引用
const cx = cell.centroid.x;
const cy = cell.centroid.y;
接下来,我们save
上下文的当前状态,并围绕单元格的中心点旋转画布
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((random.float(0, 360) / 180) * Math.PI);
ctx.translate(-cx, -cy);
酷!现在,我们可以渲染一些东西了。让我们绘制一个弧线,其结束角度为PI
或PI * 2
。对我们来说,是一个半圆或一个圆
ctx.beginPath();
ctx.arc(
cell.centroid.x,
cell.centroid.y,
cell.innerCircleRadius * 0.75,
0,
Math.PI * random.int(1, 2)
);
ctx.fill();
我们的createVoronoiTessellation
函数将一个特殊的innerCircleRadius
附加到每个cell
上——这是可以在其中心适合的最大圆圈,而不会接触任何边缘。可以将其视为一个方便的指南,用于将对象缩放到单元格的边界。在上面的代码片段中,我们使用innerCircleRadius
来确定弧线的大小。
这里有一支简单的笔,突出显示这里正在发生的事情
现在我们已经为每个单元格添加了一个“主”弧线,让我们再添加一个,概率为 25%。但是这次,我们可以将弧线的填充颜色设置为我们的工作线程的背景颜色。这样做会让我们在某些形状的中间出现一个小孔的效果!
if (random.float(0, 1) > 0.25) {
ctx.fillStyle = background;
ctx.beginPath();
ctx.arc(
cell.centroid.x,
cell.centroid.y,
(cell.innerCircleRadius * 0.75) / 2,
0,
Math.PI * 2
);
ctx.fill();
}
太棒了!现在我们只需要恢复绘图上下文即可。
ctx.restore();
就是这样!
下一步
Voronoi 镶嵌的美妙之处在于,您可以使用它们来放置任何东西。在我们的示例中,我们使用了弧线,但您可以渲染矩形、线条、三角形,任何东西!也许您甚至可以渲染单元格本身的轮廓?
这是我们的 VoronoiPattern
工作线程的一个版本,它渲染了许多小线条,而不是圆形和半圆形
随机化图案
您可能已经注意到,到目前为止,我们所有的图案都收到了一个静态的 --pattern-seed
值。这很好,但如果我们希望我们的图案每次显示时都是随机的呢?好吧,幸运的是,我们只需要在页面加载时将 --pattern-seed
变量设置为一个随机数即可。就像这样
document.documentElement.style.setProperty('--pattern-seed', Math.random() * 10000);
我们之前简要地提到了这一点,但这是一个很好的方法,可以确保网页对于每个看到它的人来说都略有不同。
下次再见
好吧,朋友们,真是太棒了!
我们一起创建了三个漂亮的图案,学习了许多实用的 Paint API 技巧,并且(希望如此!)也玩得很开心。从这里开始,我希望您能受到启发,用 CSS Houdini 创建更多生成艺术/设计!我不确定您是否和我一样,但我觉得我的作品集网站需要重新粉刷一下……
下次再见,CSS 魔法师们!
哦!在您离开之前,我有一个挑战要给您。在这个页面上运行着一个生成 Paint API 工作线程!您能发现它吗?
- 当然有一些方法可以规避此规则,但它们可能很复杂,并且不完全适合本教程。 ⮑
很棒的文章,George!时机也恰到好处;我正要开始一个新项目,您可能刚刚启发了我的设计。感谢您所有的解释和示例。
非常感谢!我很高兴听到这个消息。祝新项目顺利!
哦,是的,我当然可以发现它。它在页脚页面上。感谢您全面介绍 Paint API。随机生成的图案结果非常棒。我需要一些时间来学习本教程。但为了在我的网站上添加和自定义图案,它是值得的。非常感谢您提供此资源!我非常感谢!
谢谢您!我很高兴本教程对您有所帮助!
哦,我的天哪。这真是天才。但它是否像 SVG 一样占用大量 CPU?
谢谢!Paint API 工作线程通常应该对 CPU 使用量的影响很小。因为它们的大部分工作都在主线程之外完成,并且没有 DOM 需要跟踪(类似于
<canvas>
元素),所以我们可以创建更复杂的分层图像而无需担心性能问题。当然,这种事情总是会付出代价的,但是除非我们创建带有数十万/数百万个元素的超级复杂的图像,否则它还是很酷的!精彩的文章,George!喜欢你不断探索新创意技巧的方式。
非常感谢!
生成艺术不必依赖于随机性。正如维基百科所定义的,它是“使用自主系统创建的艺术”,这意味着您编写创建渲染的规则,而不是渲染本身。例如,如果我循环遍历 10 个元素并根据它们的索引执行不同的操作,它就是生成的,但没有涉及随机性……
这是一个很好的观点。我认为通常来说,大多数生成艺术都具有一定的随机性,但就像您所说的,它当然不必如此。我需要在那里稍微更新一下术语。非常感谢您告诉我并帮助我反思自己对生成艺术的定义!