本文代碼見:https://github.com/DesertsX/p5js-zju126
又是一年浙大校慶時
第一篇 Three.js Shader 教程總算本周上周更新了,想著在更新這個系列的同時穿插寫些簡單輕松的內(nèi)容,填些之前留下的小坑。
今天昨天前天是2023年5月21號,是浙大126周年校慶的日子。想到去年125周年校慶官方制作了個可以輸入校友專業(yè)和名字、認(rèn)證通過后會生成每個人專屬的由“燦若繁星”的浙大人姓名的粒子系統(tǒng)組成的“求是星海”的網(wǎng)頁。(網(wǎng)頁還在,但需要輸入信息才能生成,所以大家看不到,這里錄個視頻供大家觀賞下,可參見原文里的視頻內(nèi)容:「用 p5.js 實現(xiàn)圖片組成的文字效果(含2D和3D版本的代碼) - 牛衣古柳」)

當(dāng)時古柳剛接觸 Three.js Shader 沒多久,看到這個作品后就用 Yuri 油管視頻里提到的 Spector.js 插件看了下該網(wǎng)頁的 Shader 代碼,見到熟悉的 Vertex Shader 頂點著色器、熟悉的 Fragment Shader 片元著色器,果然就是粒子系統(tǒng)。


記得那會古柳一方面感慨接觸 Shader 后,“孕婦效應(yīng)”再次顯現(xiàn),到處都能看到 Shader 的應(yīng)用,這次校慶里居然就有“送上門”的;一方面覺得這些人怎么都會 Shader,仿佛就自己沒學(xué)會,非常沮喪。

不過說起來去年比較特別,印象里前些年以及今年都沒有類似專門做個網(wǎng)頁效果的,一般都是文章或視頻的形式。
帝國大廈亮燈祝賀
之所以提這件事,是古柳想到更早之前2017年120周年校慶那時,本來碰上這種滿十年的年份就感覺非常特殊、更為熱鬧,而當(dāng)時更有美國紐約帝國大廈專門為浙大120周年校慶亮燈這則“大新聞”,說可能是帝國大廈有史以來第一次為中國國內(nèi)大學(xué)校慶而亮燈,就很有排面。

畢竟素來幾所985高校間喜歡爭論誰才是中國內(nèi)地Top3的大學(xué),并且各自喜歡用各種排行榜里自己的好名次給自己臉上“貼金”。難得讓我等浙大師生或校友遇上這么個“有史以來第一次為中國國內(nèi)大學(xué)校慶而亮燈”的“大噱頭”,可不得“奔走相告”。
而那會剛自學(xué) Python 編程和爬蟲沒多久的古柳也想做點什么應(yīng)個景、湊個熱鬧,于是想到可以爬取帝國大廈官網(wǎng)的過往亮燈圖片來拼個 "ZJU 120" 的字樣。

說干就干,當(dāng)時帝國大廈的官網(wǎng)可以選擇日期以顯示當(dāng)天的亮燈圖片,直接遍歷日期用 post 請求就能爬取圖片。原本古柳以為是每天專門拍攝的照片,后來發(fā)現(xiàn)沒爬多少就重復(fù)了,其實沒多少不同的圖。

有了圖片后,古柳在隨手拿的餐巾紙上畫了下“ZJU 120”字樣以確定應(yīng)該如何布局。

然后古柳用 Python 里的 PIL 庫手動將每張圖片放到文字的位置上,當(dāng)時用的還是笨辦法手動存了每個位置坐標(biāo),但能實現(xiàn)出來就行,本文將介紹個簡單直觀的方法。
最后分別用單張圖片和不同圖片排列出文字,效果對當(dāng)時的自己來說還是很酷的。


p5.js 復(fù)現(xiàn)上述效果
交代完上述前因后果,其實本文的目的就是用 p5.js 復(fù)現(xiàn)下當(dāng)初的圖片拼湊出文字的效果。
雖然之前古柳幾乎僅在「伴隨 P5.js 入坑創(chuàng)意編程 - 牛衣古柳 - 2019.06.28」一文提到 p5.js,但這個庫真的很簡單,即便是藝術(shù)家、設(shè)計師、編程小白都能輕松上手,而且拿來做創(chuàng)意編程、生成藝術(shù)、NFT 作品、數(shù)據(jù)可視化、WebGL 3D、Shader 編程、AR/VR/XR 等都可以。

p5.js 有多簡單,讓我們一起看看。首先引入 p5.js 的庫;接著一般需要在 setup() 函數(shù)和 draw() 函數(shù)里書寫代碼,兩者都會被 p5.js 自動執(zhí)行,前者只執(zhí)行一次,可以初始設(shè)置一些內(nèi)容,比如設(shè)置畫布大小,后者會被反復(fù)調(diào)用執(zhí)行,比如一般電腦60FPS幀率就是每秒執(zhí)行60次,可以實現(xiàn)動畫、交互。因為這里僅靜態(tài)地展示圖片無需動態(tài)效果,所以只需在 setup() 里實現(xiàn)即可。
下面我們在 400x400 淺綠色背景的畫布上繪制一個深綠色填充且無描邊的矩形,其位置在(50, 50)處、寬高為(100,100),可以看到代碼非常的直觀,簡直和用 PS/AI 等軟件工具直接畫一個矩形一樣簡單。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p5.js 實現(xiàn)圖片組成的文字效果</title>
<style>
body {
margin: 0;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.6.0/p5.min.js"></script>
</head>
<body>
<script>
function setup() {
createCanvas(400, 400);
background('#e6fcf5');
noStroke();
fill('#0ca678');
rect(50, 50, 100, 100);
}
</script>
</body>
</html>

借助二維數(shù)據(jù)顯示特定形狀
繪制出一個矩形后,我們?nèi)绾螌⒁欢丫匦卫L制出特定的形狀或文字?這里介紹下古柳之前學(xué) Canvas 時從 Coding Math 這個系列教程里學(xué)到的一招,即在二維數(shù)組直接存儲所需形狀格式的數(shù)據(jù)。

比如同樣的 400x400 畫布,假如我們將其劃分成5x5的網(wǎng)格,一個單元格就是80x80,那么我們可以直接在5x5的二維數(shù)組里以下面的格式將我們想在哪些位置繪制矩形用不同數(shù)字進(jìn)行區(qū)分,1就是繪制,0就是不繪制,這樣這個數(shù)組就和實際想繪制的形狀非常直觀的對應(yīng)上,比如下圖繪制的“X”形狀。
注意這里矩形寬高 (width / row.length, y * height / grid.length) 就是80x80,直接固定寫死也問題不大,width 和 height 是創(chuàng)建畫布后就能拿到的畫布寬高,分別除以列數(shù)行數(shù)就是每個單元格的大小。
const grid = [
[1, 0, 0, 0, 1],
[0, 1, 0, 1, 0],
[0, 0, 1, 0, 0],
[0, 1, 0, 1, 0],
[1, 0, 0, 0, 1],
];
function setup() {
createCanvas(400, 400);
background('#e6fcf5');
noStroke();
fill('#0ca678');
// rect(50, 50, 100, 100);
for (let y = 0; y < grid.length; y++) {
const row = grid[y]
for (let x = 0; x < row.length; x++) {
if (row[x] === 1) {
rect(x * width / row.length, y * height / grid.length, width / row.length, height / grid.length);
}
}
}
}

在 grid 里調(diào)整形狀非常方便,比如很簡單就能變成“回”字形狀,再也不用很笨的去數(shù)每個位置具體的坐標(biāo)然后手動繪制。
const grid = [
[1, 1, 1, 1, 1],
[1, 0, 0, 0, 1],
[1, 0, 1, 0, 1],
[1, 0, 0, 0, 1],
[1, 1, 1, 1, 1],
];

用圖片替換矩形
接著我們用圖片替換矩形,在 p5.js 的 setup() 函數(shù)之前的 preload() 函數(shù)里通過 loadImage() 方法加載圖片,然后在 setup() 里通過 image() 方法將圖片在對應(yīng)位置以特定寬高放置即可,這里 image() 后四個參數(shù)和 rect() 一樣,且因為用的就是 1:1 的圖片所以直接替換即可。
const grid = [
[1, 0, 0, 0, 1],
[0, 1, 0, 1, 0],
[0, 0, 1, 0, 0],
[0, 1, 0, 1, 0],
[1, 0, 0, 0, 1],
];
let img;
function preload() {
img = loadImage('./images/0.jpeg');
}
function setup() {
createCanvas(400, 400);
background('#e6fcf5');
noStroke();
fill('#0ca678');
for (let y = 0; y < grid.length; y++) {
const row = grid[y]
for (let x = 0; x < row.length; x++) {
if (row[x] === 1) {
// rect(x * width / row.length, y * height / grid.length, width / row.length, height / grid.length);
image(img, x * width / row.length, y * height / grid.length, width / row.length, height / grid.length);
}
}
}
}

如果需要加載多張圖片,只需要放進(jìn)數(shù)組里,使用時按索引獲取對應(yīng)圖片即可。
// let img;
const num = 9;
const images = [];
function preload() {
// img = loadImage('./images/0.jpeg');
for (let i = 1; i <= num; i++) {
const img = loadImage(`./images/${i}.jpeg`)
images.push(img);
}
}
最終的“ZJU 126”效果
最后,只需套到實際圖片數(shù)據(jù)集上和基于想要的文字效果去設(shè)置 grid 即可。這里因為帝國大廈官網(wǎng)的圖片寬高不一,而本文只是演示如何復(fù)現(xiàn),就不去手動剪裁圖片了,有些拉伸變形無關(guān)緊要,簡單設(shè)置繪制的圖片高度 h 為寬度 w 的1.5倍。然后設(shè)置 grid 成 “ZJU 126” 的字樣,行列數(shù) rows cols 隨之確定,然后畫布寬高大小也能確定,最后就是遍歷繪制9張亮燈圖里的任意一張即可。
const num = 9;
const images = [];
const w = 40;
const h = w * 1.5;
const grid = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// ZJU
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0],
//
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// 126
[0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
//
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
const rows = grid.length;
const cols = grid[0].length;
function preload() {
for (let i = 1; i <= num; i++) {
const img = loadImage(`./images/${i}.jpeg`);
images.push(img);
}
}
function setup() {
createCanvas(cols * w, rows * h);
background(10);
// let i = 0;
for (let y = 0; y < rows; y++) {
const row = grid[y];
for (let x = 0; x < cols; x++) {
if (row[x] === 1) {
// const img = images[i % images.length];
const img = random(images);
image(img, x * w, y * h, w, h);
// i++;
}
}
}
}

3D 效果同樣可以
以上的實現(xiàn),是不是很簡單,而且用 grid 來擺放圖形元素如此方便,古柳想到同樣可以在 3D 里擺出立體字的效果,之所以有這個想法,是因為一直記得五十嵐威暢的這張海報,覺得蠻漂亮的。

因此古柳簡單地切換到 p5.js 的 WEBGL 模式,然后在每個位置放上 box() 立方體,并將圖片貼上去,在正交相機(jī)下做出2.5D效果如圖所示。大家覺得是上面2D的好看還是這個3D的好看呢?

這部分代碼就不做解釋了,3D WEBGL 的東西解釋起來也麻煩些,大家可自行學(xué)習(xí),此處僅供參考。
const num = 9;
const images = [];
const w = 40;
const h = w * 1.5;
const grid = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// ZJU
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0],
[0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0],
//
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
// 126
[0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0],
//
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
const rows = grid.length;
const cols = grid[0].length;
function preload() {
for (let i = 1; i <= num; i++) {
const img = loadImage(`./images/${i}.jpeg`);
images.push(img);
}
}
function setup() {
createCanvas(cols * w, rows * h, WEBGL);
background(10);
// background('#e6fcf5');
// camera 位置、朝向、自身的法向量方向,需要3個向量、9個坐標(biāo)來確定
camera(200, -400, height / 2 / tan(PI / 6), 0, 0, 0, 0, 1, 0);
ortho(-500, 500, -500, 500, 0.1, 2000);
let i = 0
for (let y = 0; y < rows; y++) {
const row = grid[y];
for (let x = 0; x < cols; x++) {
if (row[x] === 1) {
push();
noStroke();
translate((x - cols / 2) * w, (y - rows / 2) * h, -10);
// const img = random(images);
const img = images[i % images.length];
texture(img);
box(w, h, h);
pop();
i++;
}
}
}
}
照例
最后和古柳交流,也請多點贊支持!