Repository: nft-book-3D
從拿到平面設計稿,到最後完成動畫並將檔案位置、metadata 指定到 iscn-js script 需要用的 csv 檔,可分為三個大步驟:3D模型 → 動畫輸出 → 匯出成 csv。
首先在從 2D 轉 3D 邏輯時,可以先考慮最終效果來安排圖層的前後順序:
3D 模型
textStyle 及 background 都有現成的平面圖檔可以使用。要做出 3D coins ,在 p5 的開源資料庫 OpenProcessing 裡有很多藝術家們的作品,可以多參考相似的模型。
1. sketchup 建模,轉 obj 檔
在這個案例裡,我參考 jWilliam Dunn 的 MatCap 光源和材質的設定,然後使用 sketchup 做一個模型並 export 成 obj 檔案來抽換 MatCap 的 3D 模型。
2. 設定 p5.js 環境,匯入 obj 模型,寫動畫腳本
目標是生成出 1024 個 NFT,為了增加每個 NFT 的獨特性及稀缺性,我們在既有的 layer 新增不同變化:3 種 background,3 種幣色 (gold, red, sliver),2 種 textStyle。
p5 的環境設定可以參考 index.js ,preload 先定義了每個元素及設定材質 ,其中 coin-texture 的材質圖是隨機試出來的(我用 photoshop 先隨意變形出黑白不錯的效果後,再加顏色),根據光源和反光的參數設定(shader.frag & shader.vert)可以一直丟材質圖進去試出你覺得滿意的反射效果(因為效果難預測。
function preload() {
myShader = loadShader('shader.vert', 'shader.frag');
g[0] = loadImage('./01_background/bg-1.jpg');
b[1] = loadImage('./01_background/bg-2.jpg');
g[2] = loadImage('./01_background/bg-3.jpg');
const { colorIndex } = getQs();
matcap = loadImage(`./02_coin-texture/${COLORS[colorIndex]}`);
watermark[0] = loadImage('./03_watermark/text-1.png');
watermark[1] = loadImage('./03_watermark/text-2.png');
obj = loadModel('coin.obj');
}
setup 則是定義當前 coins 的座標位置、旋轉起始角度、轉速……等等的隨機條件,並存下每個 animation 的隨機參數成 json ,以及將當前的動畫錄下存成 webm 格式 。
function setup() {
// shaders require WEBGL mode to work
createCanvas(600, 800, WEBGL);
noStroke();
isCentered = random(0, 1) > 0.5;
isMirrored = random(0, 1) > 0.5;
textStyle = Math.floor(random(0, 2));
bgIndex = Math.floor(random(0, 3));
randomA = Math.floor(random(10, 100)) / 10000;
randomB = Math.floor(random(10, 100)) / 10000;
randomC = isMirrored ? -random : Math.floor(random(10, 100)) / 10000;
random = isMirrored ? -randomB : Math.floor(random(10, 100)) / 10000;
randomE = isCentered ? 0 : Math.floor(random(-200, 200));
random = isCentered ? 0 : Math.floor(random(-200, 200));
randomG = isCentered ? 0 : Math.floor(random(-200, 200));
randomH = isCentered ? 0 : Math.floor(random(-200, 200));
}
draw 則負責將當前隨機條件的元件,依照 layers 的先後順序 push 到畫布上。
function draw() {
background(0, 0, 0, 1);
push();
texture(bg[bgIndex]);
translate(0, 0, -300);
scale(1.45);
plane(600, 800);
popO();
currentTick = deltaTime / 16.6 + currentTick;
// shader sets the active shader with our shader
shader(myShader);
// Send the texture to the shader
myShader.setUniform('uMatcapTexture', matcap);
push();
translate(randomE, randomF - 100);
rotateX(currentTick * randomA);
rotatez(currentTick * randomB);
scale(0.83);
model(obi);
pop();
}
動畫輸出
1. 輸出腳本(index.js)會做的是:
- 每次輸出都根據初始條件來 random 座標、旋轉角度、轉速、材質、background、textStyle。
- 到了設定的秒數後結束 capture 並 export webm 到本機。
- 將目前使用的隨機參數 (raw metadata)export josn 到本機。
function setup() {
const { colorIndex, index } = getQs();
download(
JSON.stringify({
isCentered,
isMirrored,
textStyle,
bgIndex,
randomA,
randomb,
randomC,
randomD,
randomE,
randomF,
randomG,
randomH,
}),
`${COLORS[coLorIndex]}-$(index}. json`,
'application/json'
);
capturer = new CCapture({
framerate: 12,
format: 'webm',
name: `${COLORS[colorIndex]}-${index}`,
});
capturer.start();
}
{
"isCentered": false,
"isMirrored": false,
"textStyle": 1,
"bgIndex": 2,
"randomA": 0.0091,
"randomB": 0.0034,
"randomC": 0.0042,
"randomD": 0.0058,
"randomE": -52,
"random": 69,
"randomG": 142,
"randomH": 118
}
2. 處理 webm 檔案
為了壓縮檔案大小,跑完腳本後我們有用 FFmpeg 將 webm 批次轉成 Webp。
3. Webp 上傳 IPFS
完成後可以使用 utils/ uploadFiles 將所有 Webp 一次上傳到 IPFS,我們使用 nftport 提供的服務,可以免費上傳一定數量的檔案,只需要先註冊帳號拿到 API key 。
每個檔案上傳完,腳本都會把 ipfs_hash 寫進對應的 json file 裡,所以要確保檔名有沒有相同、檔案位置有沒有放正確。
async function runUpload() {
for (const file of files) {
const formData = new FormData();
const fileStream = fs.createReadStream(`${basePath}/files/images/${file}`);
formData.append("file", fileStream);
let url = "https://api.nftport.xyz/v0/files";
let options = {
method: "POST",
headers: {
Authorization: "process.env.XXXXX",
},
body: formData,
};
const data = await fetch(url, options)
.then((res) => res.json())
.catch((err) => console.error("error:" + err));
if (data.file_name === ".DS_Store") continue;
const fileName = path.parse(data.file_name).name;
const rawdata = fs.readFileSync(`${basePath}/output/json/${fileName}.json`);
let metaData = JSON.parse(rawdata);
metaData.ipfs_hash = data.ipfs_url;
//write into json
fs.writeFileSync(
`${basePath}/output/json/${fileName}.json`,
JSON.stringify(metaData, null, 2)
);
...
}
}
{
"isCentered": false,
"isMirrored": false,
"textStyle": 1,
"bgIndex": 2,
"randomA": 0.0091,
"randomB": 0.0034,
"random(": 0.0042,
"randomD": 0.0058,
"randomE": -52,
"random": 69,
"randomG": 142,
"randomH": 118,
"ipfs_hash": "https://ipfs.io/ipfs/bafybeibqate2qsonbdbi4yqgt6kzpcxbuty3zvizb46gblun3kf3h6bika"
}
匯出 csv
最後一步就是把目前有的 1024 個 json files 整理成一份 csv 供 scrpit 使用。
1. 整合 metadata
由於目前的 json 檔是隨機參數,所以先用 utils/convertToMeta 把這些隨機參數命名/整理成一份易讀性較高的 json。
{
"name": "《所謂「我不投資」,就是 all in 在法定貨幣》",
"description": "",
"fileName": "O-red-01",
"ipfs_hash": "ipfs://bafybeibqate2qsonbdbi4yqgt6kzpcxbuty3zvizb46gblun3kf3h6bika",
"attributes": {
"background": "Fade Out",
"publish_info_layout": "Bottom",
"coins_layout": "Random",
"coins_color": "Rose Gold",
"coin_1_rotate": { "X": 0.0091, "Z": 0.0034 },
"coin_2_rotate": { "X": 0.0042, "Z": 0.0058 },
"coin_1_position": { "X": -52, "Y": 69 },
"coin_2_position": { "X": 142, "Y": 118 }
}
}
2. 輸出成 csv
根據 script 需要的 csv 欄位 :"nftId","uri","image","metadata"
利用 utils/converToCsv 來做整合。因為想做到隨機發送,所以雖然 nft-id 是遞增的排序,但對應的 metadata 先做了打亂的處理,就可以避免用 nft-id 來推算會拿到哪個樣式的 NFT。
const readDir = `${basePath}/output/json`;
const rawData = fs.readFileSync(`${readDir}/_metadata.json`);
const converted = JSON.parse(rawData);
function shuffle(a, b) {
const num = Math.random() > 0.5 ? -1 : 1;
return num;
}
function formatIndex(index) {
if (index < 10) {
return `000${index}`;
}
if (index < 100) {
return `00${index}`;
}
if (index < 1000) {
return `0${index}`;
}
return index;
}
const data = converted.map((item) => {
return {
image: item.ipfs_hash,
metadata: item,
};
});
const sorted = data.sort(shuffle);
const populatedMeta = sorted.map((item, index) => {
return {
nftId: `moneyverse-${formatIndex(index)}`,
uri: "https://api.ckxpress.com/book-nft/metadata?book_id=1",
...item,
metadata: {
...item.metadata,
description: `#${formatIndex(index)}`,
},
};
});
const csv = parse(populatedMeta);
fs.writeFileSync(`${basePath}/output/json/_metadata.csv`, csv);
const readDir = `${basePath}/output/json`;
const rawData = fs.readFileSync(`${readDir}/_metadata.json`);
const converted = JSON.parse(rawData);
function shuffle(a, b) {
const num = Math.random() > 0.5 ? -1 : 1;
return num;
}
function formatIndex(index) {
if (index < 10) {
return `000${index}`;
}
if (index < 100) {
return `00${index}`;
}
if (index < 1000) {
return `0${index}`;
}
return index;
}
const data = converted.map((item) => {
return {
image: item.ipfs_hash,
metadata: item,
};
});
const sorted = data.sort(shuffle);
const populatedMeta = sorted.map((item, index) => {
return {
nftId: `moneyverse-${formatIndex(index)}`,
uri: "https://api.ckxpress.com/book-nft/metadata?book_id=1",
...item,
metadata: {
...item.metadata,
description: `#${formatIndex(index)}`,
},
};
});
const csv = parse(populatedMeta);
fs.writeFileSync(`${basePath}/output/json/_metadata.csv`, csv);
最後就會在 output 的資料夾裡生成一個可以拿來跑 script 的 _metadata.csv 囉!