在 2021 年初到中期,我到处都看到这种艺术风格,尤其是在品牌设计中。甚至我实际上拥有一个看起来完全一样的桌垫。这就是我所说的:
基本设置#
我做的第一件事是研究;设计师是如何做到这一点的?看起来手动构建太复杂了,但又太有组织,不像是随机的。这_必须_是程序生成的,对吧?我找到了这个教程,还有其他的。一般的想法似乎是从一些 Perlin 噪声开始,模糊它,调整级别以增加对比度,然后检测边缘。我们并不完全这样做,但我们会走类似的路线。
我们将使用three.js来构建这个。有_很多_方法来设置一个基本的游乐场,但我会坚持基础。我会快速启动一个 Vite 项目以获得热重载支持,但你实际上只需要一个 HTML 页面:
<html>
<div id="topo"></div>
<script type="module" src="./src/index.ts"></script>
</html>
在我们的 JavaScript 文件中,我们将设置一个基本的 ThreeJS 场景。如果你对这些东西不熟悉,three 的文档有一个基本的教程可以让你入门。话虽如此,我真的建议在继续之前先熟悉图形术语,因为大多数东西否则听起来就像是胡言乱语。我会大致跳过 ThreeJS 特定的内容,以便我们能进入有趣的部分。因为我们将在着色器中进行所有编码,所以我想要的只是一个简单的场景,里面有一个完全覆盖我们相机视图的平面。我将以这种方式设置一个OrthographicCamera
和我的平面:
const width = 600;
const height = 400;
const scene = new THREE.Scene();
// 创建并定位相机,使其FOV恰好映射到我们的视口
const camera = new THREE.OrthographicCamera(0, width, 0, height, 1, 3);
camera.position.z = 2;
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
// 只需要一个平面
const geometry = new THREE.PlaneGeometry(width, height);
geometry.translate(width / 2, height / 2, 0);
// 我们将从一个白色的材料开始
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide, // 确保平面的两面都被渲染。这避免了与法线相关的问题。
});
scene.add(new THREE.Mesh(geometry, material));
document.getElementById("topo").appendChild(renderer.domElement);
function frame() {
requestAnimationFrame(frame);
renderer.render(scene, camera);
}
frame();
如果一切顺利,你应该会看到一个白色的canvas
。不错。
让我们将MeshBasicMaterial
替换为ShaderMaterial
,并编写一个基本的顶点和片段着色器,以确保它们也能正常工作:
// ..snip
const vs = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fs = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0)
}
`;
const material = new THREE.MeshBasicMaterial({
vertexShader: vs,
fragmentShader: fs,
side: THREE.DoubleSide,
});
// ..snip
(好吧,听着,有更好的方法来处理着色器,而不是把它们放在一个字符串中,但每个人都有自己处理它们的方法,所以你可以按自己的方式来。在这篇文章中,我会把它们保持在一个字符串中,以保持简单。)
你的白色canvas
现在应该变成红色。完成这一切后,让我们进入有趣的部分。
着色器#
让我们创建一些 Perlin 噪声。我们可以在 JS 端(在 CPU 上)做到这一点,但由于我计划对噪声进行动画处理,这样做会很快变得昂贵,因为我们必须每秒至少执行 60 次。相反,我们将在 GPU 上执行这项工作,GPU 在这方面非常擅长。在 GLSL 中编写 Perlin 噪声函数远远超出了这篇文章的范围,所以我找到了一个,我将把它复制到我的片段着色器中,放在main
函数上方。然后我们可以使用snoise
函数的返回值来生成片段颜色:
// 在这里粘贴`noise3D.glsl`的内容
void main() {
float noise = snoise(vec3(gl_FragCoord.xy, 1.0));
gl_FragColor = vec4(vec3(noise), 1.0);
}
你可能还看不到太多,因为噪声太小了。让我们将gl_FragCoord.xy
乘以0.005
来稍微 “放大” 一下:
不过你可能会注意到黑色比白色多,这是因为snoise
函数返回的值在-1
和1
之间。片段着色器的输出被裁剪到0
和1
之间,所以我们需要 “归一化” 它。在着色器上下文中归一化只是将一个值从它所在的范围缩放到 [0-1] 的花哨说法。
void main() {
float noise = snoise(vec3(gl_FragCoord.xy * 0.005, 1.0)); // 获取噪声
noise = (noise + 1.0) / 2.0; // 归一化
gl_FragColor = vec4(vec3(noise), 1.0);
}
接下来,我们想要改变噪声,使其不再像现在这样平滑。我们想要的是 “色带”。这正是海报化的效果。这个是一个很好的资源,可以学习海报化(以及许多其他内容)。简而言之,我们试图将0
和1
之间的连续值范围(即我们的噪声)转换为离散的步骤。可以把它想象成向上 / 向下舍入值,因此0
和1
之间的所有内容都转换为1
,1
和2
之间的所有内容都转换为2
,依此类推。
但这并不_完全_像舍入,因为我们的噪声值已经在0
和1
之间。所以让我们将噪声值乘以10
,将其缩放到 [0-10],然后进行我们的 “舍入” 操作:
void main() {
float noise = snoise(vec3(gl_FragCoord.xy * 0.005, 0.0));
noise = 10.0 * (noise + 1.0) / 2.0;
float rounded = ceil(noise);
float color = rounded / 10.0; // 我们必须将舍入后的值缩放回[0-1],以便可以用作有效颜色
gl_FragColor = vec4(color, color, color, 1.0);
}
嘿,现在我们有进展了!不过我们想要的不是色带,而是这些色带的_边缘_。我们可以利用海报化过程中,我们只是向下舍入了 Perlin 噪声的实际值这一事实,而不是实现一个复杂的边缘检测算法。如果你想想,实际值和舍入值之间差异小于0.1
(或任何小阈值)的地方_大致上_就是 “边缘”!我们将在这些地方绘制一个白色像素,其他地方则绘制一个黑色像素。让我们试试:
void main() {
float noise = snoise(vec3(gl_FragCoord.xy * 0.005, 0.0));
noise = (noise + 1.0) / 2.0; // 归一化
float rounded = ceil(noise);
float rounding_error = rounded - noise;
if (rounding_error < 0.1) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
} else {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}
}
太好了!我们可以在这里结束,但我们可以做一些明显的改进。最明显的一点是改善锯齿状的线条。这很简单,只需告诉 ThreeJS 使用我们设备的像素比。我们可以通过将其添加到我们的 ThreeJS 代码中来做到这一点:
renderer.setPixelRatio(window.devicePixelRatio);
这使得一切都变得更小,因此我们可以通过 “放大” 来再次增加大小,也就是改变我们乘以gl_FragCoord.xy
的系数。我将其设置为0.003
,但你应该使用一个看起来不错的值!
其次,与其将片段涂成黑色,我们可以直接丢弃它,或者告诉 WebGL 根本不绘制它。这还有一个优点,即我们的最终材料变得透明,这意味着它将更好地与我们页面的背景融合。确保在WebGLRenderer
构造函数调用中设置alpha
参数!
void main() {
float noise = snoise(vec3(gl_FragCoord.xy * 0.003, 1.0));
noise = 10.0 * (noise + 1.0) / 2.0;
float rounded = ceil(noise);
float rounding_error = rounded - noise;
if (rounding_error > 0.1)
discard;
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
动画#
你可能注意到snoise
函数有一个第二个参数,我们到目前为止将其设置为1
。这个值实际上应该是一个 “时间” 值,是 Perlin 噪声算法的一部分。如果你将其设置为除1.0
以外的其他值,你会看到着色器中的不同模式。不过非常酷的是,这个 “时间” 值的相似值会产生 “相似” 的 Perlin 噪声模式。我所说的意思是,1.1
的 “时间” 值创建的模式与我们看到的模式仅有微小的不同。可以把它想象成一个 “相位” 参数,而不是 “时间”。我们可以利用这一点,将其设置为 ThreeJS 时钟的值。因为它不断增加,我们_应该_也能看到我们的 Perlin 噪声模式随着时间的推移缓慢变化。让我们创建一个Clock
,并将其用作我们ShaderMaterial
中的一个 uniform:
// ..snip
const clock = new THREE.Clock();
// ..snip
const material = new THREE.ShaderMaterial({
uniforms: {
time: {
value: clock.startTime,
},
},
vertexShader: vs,
fragmentShader: snoise + fs,
side: THREE.BackSide,
});
当然,我们还必须告诉我们的片段着色器有关这个 uniform 的事情,以便我们可以将其传递给snoise
函数:
uniform float time;
void main() {
float noise = snoise(vec3(gl_FragCoord.xy * 0.003, time));
noise = 10.0 * (noise + 1.0) / 2.0;
float rounded = ceil(noise);
float rounding_error = rounded - noise;
if (rounding_error > 0.1)
discard;
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
你可能会注意到你的模式发生了剧烈变化,但没有动画🤔。好吧,这是因为我们只设置了 uniform 一次,但从未更新它!我们必须确保在渲染循环中更新我们的time
uniform:
// ..snip
function frame() {
requestAnimationFrame(frame);
material.uniforms.time.value = clock.startTime + clock.getElapsedTime();
renderer.render(scene, camera);
}
frame();
哇!你应该看到一个迷幻的动画!当然,这太快了,所以我们可以通过将传递给snoise
函数的time
uniform 乘以一个小数字,例如0.01
来减慢它。
这就是全部!你还可以创建一个color
uniform,以便你可以控制在片段着色器中绘制的颜色,但我将把这留给你。这里有一些我认为效果不错的颜色组合,作为结束: