NFT化された3DモデルをPlayCanvasで可視化をするデモを作ってみました。

はじめに

GMOグローバルサイン・ホールディングス GSP事業部PlayCanvas推進室の羽賀(@mxcn3)です。
前回の記事」では、VRの機材を買おうか考えておりましたが、機器を購入し、フルトラッキングをしながらVR上のジムに通っています。

私の所属する部署は、PlayCanvasというWebGLベースのゲームエンジンの販売・サポートを行っております。また他にPhotonというネットワークエンジンの販売を行っている部署に所属をしています。今年の5月頃に3D空間上でNFTをボクセルを交換 &可視化できるデモを作成してみました。

本テックブログで以前「3DアートデータをNFT化してOpenSeaで閲覧する」としてCTO室の井上さんが投稿されています。この記事はそのスマートコントラクトを使用して作成したデモです。NFTについてやスマートコントラクトついてはそちらの記事をご覧ください。この記事では作成したデモの内容、WebGLのライブラリを使用した3Dモデルの可視化方法について紹介をします。

経緯

作成にあたった経緯としては、NFTが盛り上がっていたのと、ブロックチェーンゲームなどやOpenSeaなどで3Dモデルやアートでの利用がされているため、PlayCanvasと相性が良さそうだったことです。何ができるか分からないような状態からとりあえず、PlayCanvasを使用してモデルの交換が出来たらゴールという内容で社内向けのデモを作りはじめました。

作成したデモについて


NFTの所有者・所有トークンの可視化を3Dの空間上で行う事ができるデモです。
デモの実行には、ブラウザへMetamaskの拡張機能のインストールが必要です。また、今回はメインネットではなく、テストネット(Rinkeby)にデプロイされています。

Webブラウザから体験いただけます。
https://gmocloud-adp.github.io/space-nft-github-pages/

※送信の機能はありますがデモで表示されているNFTについては、すべて私が所有しているのでデモを利用される方は送信の機能は利用できません。(もし、テストで欲しい方などがいらっしゃいましたら別途ご連絡ください)

生成される惑星について

デモの中で表示されている惑星は、トークンを持っているウォレットアドレス毎に生成されます。
(また色や種類についてはウォレットアドレスに応じて変化します)
ジェネレーター: https://playcanv.as/b/z9jRm9I5/

制作人数

  • 企画周り 1名
  • フロントエンドエンジニア (PlayCanvasを使用したシーンの作成 / HTML, CSS , JavaScript) 1名、
  • テクニカルアーティスト(各惑星の3Dモデリング / パーティクル周り) 1名
  • バックエンド(ブロックチェーン) 1名

制作は上記の4名で作成しました。私はこの中のフロントエンド部分を担当しました。

使用ライブラリ

開発環境として「PlayCanvas」「web3.js」を利用して実装を行いました。

PlayCanvas

PlayCanvasはWebGLのゲームエンジンです。エンジンはOSSで公開をされており、WebGLのライブラリとしてはThree.jsやBabylon.jsと並んで紹介をされることが多いです。3DモデルをPlayCanvasを利用してどのように描画をしているのかをコードを交えて紹介をします。

web3.js

Metamaskの情報の確認及び、JavaScriptでスマートコントラクトに接続をするためにweb3.jsを使用します。web3.jsの利用方法はnpmパッケージをインストールをするかCDN経由で利用できます。GitHubで公開されている.jsファイルをCDN経由で読み込みます。

PlayCanvasとweb3.jsを組み合わせてNFTを可視化する方法について

PlayCanvasとweb3.jsを使用してトークンの中身の3Dモデルを表示するまでの方法を紹介します。
今回はサンプルとしてもう一つ表示までを行う単純なプロジェクトを作成ました。使用するのは単純にCameraライトを設置だけをしているプロジェクトです。こちらに下記のスクリプトを追加してNFT、Green Manを描画します。
サンプルプロジェクト(PlayCanvasのアカウントを作成後見ることができます。)

class PlayCanvasNft extends pc.ScriptType {

  async initialize(){

      // Metamaskのウォレットに接続
      const web3 = new Web3(window.ethereum);
      const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });

      // 残高の確認
      const balance = await web3.eth.getBalance(accounts[0]);

      // スマートコントラクトをABIとコントラクトアドレスを使用してデプロイされたモデルを読み込み
      const contract = new web3.eth.Contract(
        JSON.parse(this.abi),
        this.contractAddress
      );

      // トークンのURLを取得
      const tokenId = "0"; // トークンID: 0, 1, 2, 3, 4, 5....
      const tokenUri = await contract.methods.tokenURI(tokenId).call();

      // メタデータの読み込み
      const metadata = await (await fetch(tokenUri)).json();

      // モデルの表示
      // animation_url: モデルデータ(glb) image:サムネイル name: デプロイ時に作成したトークンの名前
      const {animation_url, image, name} = metadata
      this.app.assets.loadFromUrlAndFilename(animation_url,`${name}.glb`, "container", (err, asset) => {
          const entity = asset.resource.instantiateRenderEntity({
              castShadows: false,
          });
          this.app.root.addChild(entity);
      });


        // 所有者の確認
        const owner = await contract.methods.ownerOf(tokenId).call();
    }
}

pc.registerScript(PlayCanvasNft);
PlayCanvasNft.attributes.add("abi", {type: "string"});
PlayCanvasNft.attributes.add("contractAddress", {type: "string"})
1. web3.jsからMetamaskに接続 & 残高の確認をする

web3.eth.getBalanceを実行して現在のウォレットの残高を確認できます。

const web3 = new Web3(window.ethereum);
accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
await web3.eth.getBalance(accounts[0])

2. スマートコントラクトへのアクセス

デプロイされたスマートコントラクトにアクセスをするには、abiコントラクトアドレスを使用して、スマートコントラクトへアクセスをします。

const contract = new web3.eth.Contract(
    [   {       "inputs": [],       "stateMutability": "nonpayable",        "type": "constructor"   },  {       "anonymous": false,         "inputs": [             {               "indexed": true,                "internalType": "address",              "name": "owner",                "type": "address"           },          {               "indexed": true,                "internalType": "address",              "name": "approved",                 "type": "address"           },          {               "indexed": true,                "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "Approval",         "type": "event"     },  {       "anonymous": false,         "inputs": [             {               "indexed": true,                "internalType": "address",              "name": "owner",                "type": "address"           },          {               "indexed": true,                "internalType": "address",              "name": "operator",                 "type": "address"           },          {               "indexed": false,               "internalType": "bool",                 "name": "approved",                 "type": "bool"          }       ],      "name": "ApprovalForAll",       "type": "event"     },  {       "inputs": [             {               "internalType": "address",              "name": "to",               "type": "address"           },          {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "approve",      "outputs": [],      "stateMutability": "nonpayable",        "type": "function"  },  {       "inputs": [             {               "internalType": "string",               "name": "tokenURI",                 "type": "string"            }       ],      "name": "createCollectible",        "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "nonpayable",        "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "from",                 "type": "address"           },          {               "internalType": "address",              "name": "to",               "type": "address"           },          {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "safeTransferFrom",         "outputs": [],      "stateMutability": "nonpayable",        "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "from",                 "type": "address"           },          {               "internalType": "address",              "name": "to",               "type": "address"           },          {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           },          {               "internalType": "bytes",                "name": "_data",                "type": "bytes"             }       ],      "name": "safeTransferFrom",         "outputs": [],      "stateMutability": "nonpayable",        "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "operator",                 "type": "address"           },          {               "internalType": "bool",                 "name": "approved",                 "type": "bool"          }       ],      "name": "setApprovalForAll",        "outputs": [],      "stateMutability": "nonpayable",        "type": "function"  },  {       "anonymous": false,         "inputs": [             {               "indexed": true,                "internalType": "address",              "name": "from",                 "type": "address"           },          {               "indexed": true,                "internalType": "address",              "name": "to",               "type": "address"           },          {               "indexed": true,                "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "Transfer",         "type": "event"     },  {       "inputs": [             {               "internalType": "address",              "name": "from",                 "type": "address"           },          {               "internalType": "address",              "name": "to",               "type": "address"           },          {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "transferFrom",         "outputs": [],      "stateMutability": "nonpayable",        "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "owner",                "type": "address"           }       ],      "name": "balanceOf",        "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [],       "name": "baseURI",      "outputs": [            {               "internalType": "string",               "name": "",                 "type": "string"            }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "getApproved",      "outputs": [            {               "internalType": "address",              "name": "",                 "type": "address"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "owner",                "type": "address"           },          {               "internalType": "address",              "name": "operator",                 "type": "address"           }       ],      "name": "isApprovedForAll",         "outputs": [            {               "internalType": "bool",                 "name": "",                 "type": "bool"          }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [],       "name": "name",         "outputs": [            {               "internalType": "string",               "name": "",                 "type": "string"            }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "ownerOf",      "outputs": [            {               "internalType": "address",              "name": "",                 "type": "address"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "bytes4",               "name": "interfaceId",              "type": "bytes4"            }       ],      "name": "supportsInterface",        "outputs": [            {               "internalType": "bool",                 "name": "",                 "type": "bool"          }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [],       "name": "symbol",       "outputs": [            {               "internalType": "string",               "name": "",                 "type": "string"            }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "uint256",              "name": "index",                "type": "uint256"           }       ],      "name": "tokenByIndex",         "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [],       "name": "tokenCounter",         "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "address",              "name": "owner",                "type": "address"           },          {               "internalType": "uint256",              "name": "index",                "type": "uint256"           }       ],      "name": "tokenOfOwnerByIndex",      "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [             {               "internalType": "uint256",              "name": "tokenId",              "type": "uint256"           }       ],      "name": "tokenURI",         "outputs": [            {               "internalType": "string",               "name": "",                 "type": "string"            }       ],      "stateMutability": "view",      "type": "function"  },  {       "inputs": [],       "name": "totalSupply",      "outputs": [            {               "internalType": "uint256",              "name": "",                 "type": "uint256"           }       ],      "stateMutability": "view",      "type": "function"  } ],
    0xcf56775AB6BfB3c05045e1dE5A226d9d08F0A897
);

ブロックチェーン側の担当をされている方にスマートコントラクトのデプロイを行っていただきました。
abi(JSON形式)とコントラクトアドレスを使用してデプロイされたスマートコントラクトにアクセスをします。

abi: コントラクトにおけるアプリケーションバイナリインターフェース(Application Binary Interface, ABI)はイーサリアムエコシステム内でコントラクトに接続するスタンダードなインターフェースです。コントラクトもABIもブロックチェーン外で作成され、ABIはコントラクトとコントラクトを接続するために使用されます。 https://solidity-jp.readthedocs.io/ja/latest/abi-spec.html

3. NFTのメタデータを取得

トークンIDtokenURIを使用してメタデータの情報を取得します。
HTTPのリクエストを送ることでメタデータをで取得できます。

const tokenId = 0; // トークンID: 0, 1, 2, 3, 4, 5....
const tokenUri = await contract.methods.tokenURI(tokenId).call(); // https://ipfs.io/ipfs/QmPGTGVrspsnYm7JNobPSQ4pHnfmAXxh4CPMqbGz2TZFCA

// メタデータの読み込み
const metadata = await (await fetch(tokenUri)).json(); 
/*
{
  "name": "Green man",
  "image": "https://ipfs.io/ipfs/QmVRhTsedPSDFAG5fub9pQdXxuroCqU4c1Ff2LgqCAwmyh",
  "animation_url": "https://ipfs.io/ipfs/QmVjARX3ZXTGTdUn1US4osSXtBieYZMNZDN83vNoXFpqro"
}
*/

3Dモデルのデータについては直接データを保存せず、参照先のURIを保存しています。トークンIDから利用してメタデータを取得します。今回は、JSON形式のレスポンスのanimation_urlに3Dモデルファイルが入っています。

4. 3DモデルをPlayCanvasで表示する

PlayCanvasではglb形式については、URLから直接読み込みできます。loadFromUrlAndFilename関数を使用して、URLから3Dモデルを読み込みます。他のマーケットに準拠をanimation_urlに設定してあるURIからをPlayCanvasで3Dモデルを読み込むことで3D空間上に配置することができます。

const {animation_url, name} = metadata
this.app.assets.loadFromUrlAndFilename(animation_url,`${name}.glb`, "container", (err, asset) => {
    const entity = asset.resource.instantiateRenderEntity({
            castShadows: false,
    });
  this.app.root.addChild(entity);
});

この流れでNFTをPlayCanvasで表示までを行うことができます。
サンプルについてはこちらをご覧ください。

NFTの交換について

所有者の確認

ownerOfを実行することでトークンIDから所有者のウォレットアドレスを確認ができます。

const tokenId = 0;
const owner = await contract.methods.ownerOf(tokenId).call(); // 0x0072Feb18fC0d04F6afD7981E9EA2A406d275504

NFTの送信

所有しているNFTを送信をするには、トークンIDと、送り先のウォレットアドレスを指定してtransferFrom実行することでNFTの送信をできます。

const tokenId = 0;
// 送信元のアドレス
const from = "0x0072Feb18fC0d04F6afD7981E9EA2A406d275504"
// 送信先のアドレス
const to = "0x45ef54eF9CB1Bb1757156C242612AbF50435183f"
await contract.methods.transferFrom(from, to, tokenId).send({from})

テストネット用のETHを貰う方法

送信の際に必要なテストネット(Rinkeby)のGAS代(ETH)は下記の方法で貰うことができます。トークンの送信などをする場合には必要となります。下記の手順でテストネット用のETHを貰うことができるので下記の手順で取得します。

  1. 配布元のウェブページにアクセス
  1. SNSにウォレットアドレスを書き込み

こちらのURLにアクセスをするとSNSのURLを入力するフォームが出ているページが表示されます。SNSでテストネットのウォレットアドレスを書き込みます。

  1. Give me Etherをクリック

書き込んだSNSの投稿URLを入力して、Give Me Etherをクリックします。
これで下記の量のETHを必要に応じて貰うことができます。

3 Ethers / 8 hours
7.5 Ethers / 1day
18.75 Ethers / 3days

今回は、3Ethers / 8hoursを選択します。
これで、自分のウォレットにテストネット用のETHが送金されます。

あとがき

このような形でNFTを可視化と交換のデモを作成してみました。
なにかありましたら@mxcn3へお願いします。

今回初めてOpenSeaやスマートコントラクトを使用させていただきましたが、OpenSea等のマーケットを見ているとHTML5ゲームも出品されており、様々なプロジェクトがNFTで出品されていることを知ることができました。