在 2021 年初至中期,我到處都看到這種藝術風格,特別是在品牌設計中。甚至我還擁有一個看起來完全一樣的桌墊。這就是我所說的:
基本設置#
我做的第一件事是研究;設計師是如何做到這一點的?這似乎太複雜,無法手動構建,但又太有組織,無法隨機生成。這一定是程序生成的,對吧?我找到了一個這個教程,還有其他的。一般的想法似乎是從一些 Perlin 噪聲開始,模糊它,調整級別以增加對比度,然後檢測邊緣。我們不完全這樣做,但我們會走一條相似的路。
我們將使用three.js來構建這個。設置基本的遊樂場有很多種方法,但我會堅持基本。我會快速啟動一個 Vite 項目以獲得熱重載支持,但你實際上只需要一個 HTML 頁面:
<html>
<div id="topo"></div>
<script type="module" src="./src/index.ts"></script>
</html>
在我們的 JavaScript 文件中,我們將設置一個基本的 ThreeJS 場景。如果你對這些東西不熟悉,three 的文檔有一個基本的教程可以讓你入門。話雖如此,我真的建議在進一步之前先熟悉圖形術語,因為大多數東西否則聽起來會像胡言亂語。我會大多數快速瀏覽 ThreeJS 特定的內容,以便我們能夠進入有趣的部分。因為我們將在著色器中進行所有編碼,所以我希望從 Three 獲得的只是覆蓋我們相機視圖的簡單場景。我將這樣設置我的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,以便你可以控制在片段著色器中繪製的顏色,但我將這留給你。這裡有幾個我認為效果不錯的顏色組合,作為結尾: