Three.jsを使ったフルオンチェーンNFT「RollerCoaster」|JavaScriptコードをPNG画像に変換しオンチェーン格納する斬新な仕組みについて解説

こんにちは、フルオンチェーンNFTクリエイターのnawooです。

nawoo

今回は、Three.jsを使ったフルオンチェーンNFTのRollerCoasterについて解説していきます。

まずは、昨年12月に投稿された、以下 xtremetom氏のツイートをご覧ください。

こちらは『Three.jsを使ったフルオンチェーンNFT』なのですが、先日記事で取り上げた「dom氏のROSES」とは異なる方法で作られているようです。

出典:testnets.opensea.io/assets/goerli/0xfccef97532caa9ddd6840a9c87843b8d491370fc/1
nawoo

さて、先ほど掲載した一連のツイートスレッドを読むと、「RollerCoaster」に関して以下のような説明がされています。

  • すべてのJavaScriptをバンドル (1ファイルにまとめる)
  • バンドルされたJavaScriptをPNG画像に変換する
  • PNG画像を圧縮する
  • PNG画像をBase64エンコードする
  • 24KBごとに分割する
  • SSTORE2を使ってオンチェーンに保存する
  • Inline Assemblyを使ってデータを結合する
  • ブラウザの標準機能を使って、PNG画像を元のJavaScriptコードに戻す

特に、「JavaScriptコードをPNG画像に変換する」という部分で驚いたのですが、実際にそのPNG画像がツイートされています。

出典:twitter.com/xtremetom/status/1600542223250202624

どのようにしてJavaScriptコードをPNG画像に変換したのか、そして、どうやって元のJavaScriptコードに戻しているのかなど、非常に興味深い作品です。

nawoo

ということで本記事では、フルオンチェーンNFT「RollerCoaster」について、ROSESと比較しながら解説していきます。

ちなみに、RollerCoasterのJavaScriptコードはThree.js公式サイトのexamplesにあるものだそうです。
・参考: threejs.org/examples/#webxr_vr_rollercoaster

でははじめに、この記事の構成について説明します。

STEP
JavaScriptのバンドル

まず、RollerCoasterではThree.jsも含めてすべてのJavaScriptファイルを1つにまとめられている点について触れていきます。

STEP
tokenURIを確認しよう

続いて、フルオンチェーンNFT「RollerCoaster」のコントラクトのtokenURIを確認していきます。

STEP
メインコントラクト

さらに、RollerCoasterのメインコントラクト「DataCompressionTest.sol」について解説していきます。

STEP
LibraryStorageコントラクト

その後、データコントラクトの分割や結合を簡単に行うためのコントラクト「LibraryStorage.sol」について、基本的な使い方も含めて概観していきます。

STEP
実際に作ってみました

最後に、RollerCoasterの手法を使って3DフルオンチェーンNFTを作成する手順などについて解説します。

本記事が、フルオンチェーンNFT「RollerCoaster」の概要や仕組み、3DフルオンチェーンNFTの開発手順などについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。

※本記事は一般的な情報提供を目的としたものであり、法的または投資上のアドバイスとして解釈されることを意図したものではなく、また解釈されるべきではありません。ゆえに、特定のFT/NFTの購入を推奨するものではございませんので、あくまで勉強の一環としてご活用ください。

イーサリアムnaviの活動をサポートしたい方は、「定期購読プラン」をご利用ください。

目次

JavaScriptのバンドル

ROSESの場合は、Three.jsとメインのJavaScriptコードは別のファイルになっていました。しかしRollerCoasterの場合は、Three.jsも含めてすべてのJavaScriptファイルを1つにまとめられています。

JavaScriptファイルを1つにまとめる(バンドル)には、webpackを使います。

1ファイルにまとめた上に、軽量化(Minify)までしてくれるので便利です。また、JavaScriptが1ファイルになることで、HTMLやコントラクトもシンプルになります。

デメリットとしては、Three.jsも含めるとバンドルしたJavaScriptのコードが大きくなってしまうので、デプロイに必要なガス代が高くなってしまいます。

また、以下の記事で作成した3DフルオンチェーンNFTでは、ROSESのThree.jsを再利用していました。

ROSESのThree.jsは、独立したデータコントラクトとしてデプロイされているので、別のプロジェクトから再利用することができました。

しかし、RollerCoasterのThree.jsは、他のJavaScriptコードと1つのファイルにまとめられているので、別のプロジェクトから利用することはできず、「ライブラリの再利用」という観点からはデメリットと言えるかもしれません。

tokenURIを確認しよう

まずは、コントラクトのtokenURIを確認してみましょう。

dataURLをデコードすると、メタデータはこのようになっていました。

{
  "animation_url":"data:text/html,%3Cstyle%3Ehtml%2Cbody%7Bpadding%3A0%3Bmargin%3A0...",
  "name":"Test 1"
}
nawoo

ROSESと同じように、animation_urlにHTMLが設定されていることが分かりますね。

HTMLの内容を確認してみると、styleタグが1つ、scriptタグが2つありました。ROSESに比べるとかなりシンプルですね。

2つのscriptを順に見ていきます。

<!-- style -->
<style>
  html, body { padding: 0; margin: 0; }
</style>

<!-- script 1 -->
<script>
  var data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAYgAAAGICA...mCC';
</script>

<body>
  <!-- script 2 -->
  <script src="data:text/javascript;base64,ZnVuY3Rpb24gaW5qZWN0U2NyaXB0K...pOw=="></script>
</body>

PNG画像データの定義 (script 1)

script 1 ではdata変数が定義されており、data:image/png;base64,〜とあるので、Base64エンコードされたPNG画像であることが分かります。

デコードしてPNG画像を取り出してみると、xtremetom氏のツイートで紹介されていたものと同じ画像が出てきました。

出典:twitter.com/xtremetom/status/1600542223250202624

392×392ピクセルの画像で、ファイルサイズは150KBでした。

nawoo

PNG画像にも色々な種類がありますが、PNG-32という1ピクセルあたり32ビットのタイプです。

JavaScriptコードをPNG画像に変換する仕組み

ツイートによると「バンドルされたJavaScriptコードをPNG画像に変換する」とのことですが、一体どのようにして変換しているのでしょうか。

PNG-32では、ピクセルごとに『RGBA』という4つのチャンネルが、それぞれ8ビット(=1バイト)で合計32ビットの情報を持ちます。

左上のピクセルから順に、RGBA, RGBA, RGBA, …と並んでいます。

通常は、ピクセルごとの色情報をRGBAに格納します。(R=赤、G=緑、B=青、A=アルファチャンネル(透明度) )

例えば、赤色ならRGBA=(255,0,0,255)、黄色はRGBA=(255,255,0,255)、半透明の薄い青色はRGBA=(0,0,128,128)というように、色情報を格納します。

nawoo

この色情報を格納する場所に、強引にJavaScriptコード(=文字列)を格納したわけです。

例として、Hello World!という文字列を格納してみます。 Hello World!をバイト(0〜255)で表すと 72,101,108,108,111,32,87,111,114,108,100,33 の12バイトです。1ピクセルが4バイトなので、3ピクセルの画像に格納できます。

  • 座標(0,0)のピクセルのRGBAには、Hell を格納
  • 座標(1,0)のピクセルのRGBAには、o Wo を格納
  • 座標(2,0)のピクセルのRGBAには、rld! を格納

座標(0,0)のピクセルはRGBA=(72,101,108,108)となり、カラーコードでは #48656c6c となります。

このような方法で、文字列をピクセルデータに変換することができます。

nawoo

このようにして作成した画像をPNG形式で保存すると、圧縮されるという仕組みです。

先ほどのツイートのPNG画像は392×392ピクセルのサイズだったので、392x392x4=614,656バイトの情報が格納されていることがわかります。

出典:twitter.com/xtremetom/status/1600542223250202624

614KBのJavaScriptコードをPNG画像として圧縮することで、150KBまで減らすことができたのです。

nawoo

PNG画像は可逆圧縮なので元のデータを失うことなく復元できます。JPGなど不可逆圧縮のタイプでは、この圧縮方法は使えません。

実際にJavaScriptコードからPNG画像を作る方法は、後ほど紹介します。

PNG画像からJavaScriptコードを復元 (script 2)


script 2は、PNG画像から元のJavaScriptコードを復元するためのコードです。

<!-- script 2 -->
<script src="data:text/javascript;base64,ZnVuY3Rpb24gaW5qZWN0U2NyaXB0K...pOw=="></script>

src属性に、Base64エンコードされたJavaScriptコードが指定されているので、これをデコードしてみました。

nawoo

Minifyされていたので、読みやすく修正したのがこちらです。

function injectScript(data) {
  // canvasタグを作成
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl');
  // テクスチャを作成
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // Imageを作成してdata(PNG画像)を読み込む
  const image = new Image();
  image.src = data; // ※1
  image.addEventListener('load', function () {
    const w = image.width;
    const h = image.height;
    // テクスチャを使用する準備
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.generateMipmap(gl.TEXTURE_2D);
    // フレームバッファを作成
    const framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
    // フレームバッファにテクスチャを割り当て
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
    if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE) {
      const buffer = new Uint8Array(w * h * 4);
      // フレームバッファから一括でピクセルデータを読み込む
      gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buffer); // ※2
      // ピクセルデータを文字列(JavaScriptコード)に変換して、Base64エンコード
      const decoder = new TextDecoder('utf-8');
      const b64 = btoa(decoder.decode(buffer)); // ※3
      // scriptタグの作成
      const script = document.createElement('script');
      // srcにdataURL化したJavaScriptコードを指定
      script.setAttribute('src', 'data:text/javascript;base64,' + b64.trim()); // ※4
      // documentにscriptタグを追加
      document.head.appendChild(script);
    }
    // canvasタグを削除
    canvas.remove();
  });
}
// data(PNG画像)を引数に渡して実行
injectScript(data);

injectScript関数では、以下の処理を行なっています。

  • data変数に入っているPNG画像のdataURLをテクスチャとして読み込む (※1)
  • テクスチャのピクセルデータをreadPixelsでバイト配列(Uint8Array)として読み込む (※2)
  • TextDecorderでバイト配列(Uint8Array)から文字列(JavaScriptコード)にデコードする (※3)
  • scriptタグを生成してsrc属性にJavaScriptコードを設定する (※4)
nawoo

これらは外部ライブラリを一切使わずに、すべてJavaScriptの標準機能で実装しています。

ROSESの場合は、データをgzipで圧縮して外部ライブラリのfflateで復元していたため、fflateをデータコントラクトから読み込む処理が別途必要でした。しかし、RollerCoasterでは外部ライブラリを読み込む必要はありません。

また、PNG画像の圧縮とgzipの圧縮はどちらもDEFLATEというアルゴリズムを使っているので、圧縮効率はほとんど同じです。

xtremetom氏の発言によると、Three.jsを圧縮した場合にgzipでは200KBだったのが、PNG画像の圧縮では199KBだったそうです。ほんの少しですがPNG圧縮の方が小さくなっています。

このことからも、PNG画像を使ってJavaScriptコードを圧縮するのは、非常に優れた手法だと言えるのではないでしょうか。

【補足】script 2で使われているWebGLについては、下記サイトを参考にしました。
WebGL でのテクスチャの使用 – WebAPI
フレームバッファ – wgld.org
readPixels – wgld.org

メインコントラクト

nawoo

RollerCoasterのメインコントラクトは、DataCompressionTest.solです。ROSESに比べるとシンプルなものになっています。

出典:goerli.etherscan.io/address/0xfccef97532caa9ddd6840a9c87843b8d491370fc#code#F1

tokenURI関数

tokenURI関数では、animation_urlnameを含むメタデータ(JSON)を作成しています。

addCodeLibrariesAsImage関数でscript 1(PNG画像の定義)を作成し、 addRenderCode関数でscript 2(JSコードの復元)を作成しています。

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    string memory tokenIdStr = uint2str(tokenId);
    return
        string.concat(
            BEGIN_JSON,
            string.concat(
                BEGIN_METADATA_VAR("animation_url", false),
                "data%3Atext%2Fhtml%2C", // data:text/html,
                "%253Cstyle%253Ehtml%252Cbody%257Bpadding%253A0%253Bmargin%253A0%253B%257D%253C%252Fstyle%253E", // <style>...</style>
                addCodeLibrariesAsImage(), // ← ここで script 1 を作成
                "%253Cbody%253E", // <body>
                addRenderCode(), // ← ここで script 2 を作成
                "%253C%252Fbody%253E", // </body>
                END_METADATA_VAR(false),
                BEGIN_METADATA_VAR("name", false), // name="Test 1"
                "Test%20",
                tokenIdStr,
                "%22",
                END_JSON
            )
        );
}

addCodeLibrariesAsImage関数

addCodeLibrariesAsImage関数では、script 1を作成しています。

Base64エンコードされたPNG画像データは、LibraryStorageコントラクトを使って読み込んでいます。

function addCodeLibrariesAsImage() internal view returns (string memory) {
    LibraryStorage libraryStorage = LibraryStorage(_libStorageAddress);
    return
        string.concat(
            BEGIN_SCRIPT,
            "var%2520data%2520%253D%2520%2522", // var data = "
            "data%253Aimage%252Fpng%253Bbase64%252C", // data:image/png;base64,
            libraryStorage.getLibrary("RollerCoaster"), // ←ここでPNG画像を読み込む
            "%2522%253B", // ";
            END_SCRIPT
        );
}

作成されるコード(script 1)はこちらです。

nawoo

PNG画像がdataURL形式で定義されています。

<script>
  var data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAYgAAAGICA...mCC';
</script>

addRenderCode関数

addRenderCode関数は、PNGからJavaScriptコードを復元するためのscript 2を作成します。

renderCode変数に、Base64エンコードしたJavaScriptが入っています。

function addRenderCode() internal view returns (string memory) {
    string memory renderCode = "ZnVuY3Rpb24gaW5qZWN0U2NyaXB0KGUpe2NvbnN0IHQ9ZG9jdW1...pOw==";
    return
        string.concat(
            BEGIN_SCRIPT_DATA,
            "data%253Atext%252Fjavascript%253Bbase64%252C", // data:text/javascript;base64,
            renderCode,
            END_SCRIPT_DATA
        );
}
nawoo

作成されるコード(script 2)はこちらです。

<script src="data:text/javascript;base64,ZnVuY3Rpb24gaW5qZWN0U2NyaXB0K...pOw=="></script>

LibraryStorageコントラクト

LibraryStorage.solは、データコントラクトの分割や結合を簡単に行うためのコントラクトです。

出典:goerli.etherscan.io/address/0xfccef97532caa9ddd6840a9c87843b8d491370fc#code#F2#L5

ROSESでは、Three.jsを9つのデータコントラクトに分割して保存し、 データを読み込む際にはDataChunkCompiler.solのcompile9関数で9つのアドレスを指定して結合していました。

nawoo

LibraryStorageコントラクトでは、分割データの結合がより簡単に、ガス節約して行えるようになっています。

基本的な使い方

分割データの保存には addChunk関数、データの結合には getLibrary関数を使います。

データを保存するまでの手順は以下のようになります。

  1. JavaScriptコードを一つにまとめる(バンドル) → 614KB
  2. PNG画像に変換して圧縮 → 150KB
  3. PNG画像をBase64エンコード → 200KB
  4. 24KBごとに9つに分割してLibraryStorageコントラクトのaddChunk関数で保存する

9つの分割データをaddChunk関数で保存する際には、同じ名前("RollerCoaster")を指定して保存します。

// 分割データの保存
libraryStorage.addChunk("RollerCoaster", chunk1);
libraryStorage.addChunk("RollerCoaster", chunk2);
...
libraryStorage.addChunk("RollerCoaster", chunk8);
libraryStorage.addChunk("RollerCoaster", chunk9);

データ読み込む際は、getLibrary関数を使います。

nawoo

このとき名前("RollerCoaster")を指定するだけで、9つの分割データを1つに結合した状態で取得できます。

// データの読み込み
libraryStorage.getLibrary("RollerCoaster"), // ←これだけで9つのデータコントラクトを結合したものを取得

自分でデータコントラクトのアドレスを管理しなくても、名前を付けて保存し、名前を指定して読み込みができるので、とても便利です。まるでパソコンでファイルを扱っているかのようです。

余談ですが、この仕組みを更に進化させた「EthFS: Ethereum File System」というプロジェクトがあります。

誰でもファイルをブロックチェーン上にアップロードして、ファイル名を指定して簡単に呼び出せるという仕組みで、p5.jsのような大きなファイルもアップロードされています。

nawoo

EthFSについては、また近いうちに詳しく紹介できればと思っています。

addChunk関数

addChunk関数では、SSTORE2を使って分割データを保存しています。

SSTORE2.writeでデータを書き込み、戻り値のアドレスを名前ごとに用意された配列に追加しています。

mapping(string => address[]) _libraries2; // 名前 => アドレス配列 のマッピング
...
function addChunk(string calldata name, string calldata chunk) public isOwner {
    // SSTORE2.writeの戻り値(=データコントラクトのアドレス)を配列に追加する
    _libraries2[name].push(SSTORE2.write(bytes(chunk)));
}

getLibrary関数

getLibrary関数は、複数のデータコントラクトから読み込んだデータを結合しますが、 ガス節約のためにInline Assemblyを使っています。

ROSESやSSTORE2を使ったケースと比較してみましょう。ROSESで使っているDataChunkCompiler.solのcompile9関数では、 各データコントラクトから読み込んだデータをabi.encodePackedで結合しています。

nawoo

この場合、各データdata1.data()data9.data()は一旦メモリ領域に確保され、 それとは別に結合後の値もメモリ領域に確保されるので、メモリを多く使ってしまいます。

// 9つのデータコントラクト(Three.js)を結合する (ROSESの場合)
function compile9(address chunk1, address chunk2, ... address chunk8, address chunk9) 
    public view returns (string memory) {

    IDataChunk data1 = IDataChunk(chunk1);
    IDataChunk data2 = IDataChunk(chunk2);
    ...
    IDataChunk data8 = IDataChunk(chunk8);
    IDataChunk data9 = IDataChunk(chunk9);
    return string(abi.encodePacked(
        data1.data(), 
        data2.data(),
        ...
        data8.data(),
        data9.data()
    ));
}

また、SSTORE2.readを使う場合は、各データコントラクトからデータを読み込んで、 abi.encodePackedbytes.concatを使ってデータを結合します。

この場合も、各データはいったんメモリ領域に確保されます。

// SSTORE2.readを使ったデータ結合例
function concat(address chunk1, address chunk2, ... address chunk8, address chunk89)
    public view returns (string memory) {

    return string(abi.encodePacked(
      SSTORE2.read(chunk1),
      SSTORE2.read(chunk2),
      ...
      SSTORE2.read(chunk8),
      SSTORE2.read(chunk9),
    ));
}

一方、LibraryStorageのgetLibrary関数では、SSTORE2.read関数を使わずにInline Assemblyを使い、個々のデータをメモリ領域に確保することなく、結合後の変数のメモリ領域に直接書き込んでいきます。

こうすることで、メモリ使用量が大幅に減り、結果としてガスの使用量がかなり節約できます。

function getLibrary(string calldata name) public view returns (string memory o_code) {
    // マッピングからデータコントラクトのアドレス配列を取得
    address[] memory chunks = _libraries2[name];
    unchecked {
        assembly {
            // アドレス配列のサイズ(=データの分割数)
            let len := mload(chunks)
            // トータルサイズ(データ結合後のデータサイズ+ヘッダ分の32バイト)
            let totalSize := 0x20 // ヘッダ分の32バイトを最初に追加しておく
            // 各分割データのサイズ
            let size
            // 戻り値(o_code)のポインタを取得
            o_code := mload(0x40) // free memory pointerから読み込み

            // 分割データの数だけループ
            let targetChunk
            for {
                let i := 0
            } lt(i, len) {
                i := add(i, 1)
            } {
                // i番目の分割データのアドレスを取得 (chunks[i])
                targetChunk := mload(add(chunks, add(0x20, mul(i, 0x20))))
                // 分割データのサイズを取得
                size := sub(extcodesize(targetChunk), 1)
                // データコントラクトからデータをコピー (SSTORE2.readと同じ)
                extcodecopy(targetChunk, add(o_code, totalSize), 1, size)
                // トータルサイズを更新
                totalSize := add(totalSize, size)
            }

            // 戻り値(o_code)のヘッダにデータサイズを保存
            mstore(o_code, sub(totalSize, 0x20))
            // free memory pointerを更新(トータルサイズにパディングを追加して32バイト単位に揃える)
            mstore(0x40, add(o_code, and(add(totalSize, 0x1f), not(0x1f))))
        }
    }
}

Inline Assemblyで直接メモリ操作する場合は、まずfree memory pointer(0x40)から空きメモリのポインタを読み込みます。

nawoo

bytesやstringでは、先頭の32バイト(ヘッダ)にデータサイズを格納し、その次からデータを書き込みます。メモリ操作が終わったら、free memory pointerを更新します。

上のコードでは、以下の処理をすることで、個々の分割データをメモリに確保することなく、データの結合を行っています。

  • free memory pointerから戻り値(o_code)のポインタを取得
  • totalSizeの初期値を32に設定 (ヘッダの分を先にすすめる)
  • 分割データの数だけループ
    • 分割データをo_code+totalSizeの位置にコピー
    • totalSizeに分割データのサイズを追加して更新
  • o_codeのヘッダにtotalSizeを書き込む
  • free memory pointerを更新する

このコードを参考にして、以下記事で書いた「724KBのJPG画像のフルオンチェーンNFT」を改良したところ、724KBから1251KBまで大幅に増やすことができました。

出典:goerli.etherscan.io/address/0x4834224d012d61c8ef3c9db4c7146ada85d00887#code#F1#L58

最後に次章では、RollerCoasterの手法を使って3DフルオンチェーンNFTを作成する手順などを中心に、「定期購読プラン」登録者向けにまとめています。ご興味あればご覧ください。

実際に作ってみました


この続き: 1,927文字 / 画像3枚

この続きは、 定期購読プランメンバー専用です。
Already a member? ここでログイン

まとめ

今回は、フルオンチェーンNFT「RollerCoaster」について、ROSESと比較しながら解説を行いました。

本記事が、フルオンチェーンNFT「RollerCoaster」の概要や仕組み、3DフルオンチェーンNFTの開発手順などについて理解したいと思われている方にとって、少しでもお役に立ったのであれば幸いです。

また励みになりますので、参考になったという方はぜひTwitterでのシェア・コメントなどしていただけると嬉しいです。

今回ご紹介したRollerCoasterは、dom氏のROSESとは異なる手法で作られており、特に「PNG画像を使った圧縮方法」には驚いた次第です。

nawoo

ROSESに刺激を受けて、新しい手法やプロジェクトがグローバルで次々に生まれてきています。今後の展開がとても楽しみですね。

また、本記事のコントラクトやスクリプトはこちらのGitHubで公開しています。記事中では説明しきれなかった部分もありますので、ぜひ参考にしてください。

イーサリアムnaviを運営するSTILL合同会社では、web3/crypto関連のリサーチ代行、アドバイザー業務、その他(ご依頼・ご提案・ご相談など)に関するお問い合わせを受け付けております。

まずはお気軽に、こちらからご連絡ください。

みんなにも読んでほしいですか?
  • URLをコピーしました!
目次