Table of Contents

NFT-Book #3D book cover 開發過程

Table of Contents

Repository: nft-book-3D

從拿到平面設計稿,到最後完成動畫並將檔案位置、metadata 指定到 iscn-js script 需要用的 csv 檔,可分為三個大步驟:3D模型 → 動畫輸出 → 匯出成 csv。

首先在從 2D 轉 3D 邏輯時,可以先考慮最終效果來安排圖層的前後順序:

示意圖。如果要符合《所謂我不投資 就是 all in 在法定貨幣》的 2D 書封設計,3D 基本需要有三層 layers ,前中後分別是:textStyle,coins,background (先定義好,在 p5 裡就比較好安排 push 的時機)
示意圖。如果要符合《所謂我不投資 就是 all in 在法定貨幣》的 2D 書封設計,3D 基本需要有三層 layers ,前中後分別是:textStyle,coins,background (先定義好,在 p5 裡就比較好安排 push 的時機)

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.jspreload 先定義了每個元素及設定材質 ,其中 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)會做的是:

  1. 每次輸出都根據初始條件來 random 座標、旋轉角度、轉速、材質、background、textStyle。
  2. 到了設定的秒數後結束 capture 並 export webm 到本機。
  3. 將目前使用的隨機參數 (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
}
NFT-Book:透過輸出腳本,測試三種 coin_color 的效果
透過輸出腳本,測試三種 coin_color 的效果

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 囉!