Three.jsを使った3DフルオンチェーンNFTを作ってみよう【開発】

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

本記事では、以前取り上げたdom氏の制作した3DフルオンチェーンNFT「ROSES」のような、Three.jsを使ったフルオンチェーンNFTを作成する方法について解説します。

nawoo

一般的には「フルオンチェーンNFT」といえばドット絵やSVGが多い印象ですが、Three.jsを使うと『3Dでの豪華なオンチェーン表現』が可能になります。

Three.jsはとても機能が多いので、今回は簡単なサンプルコードを例に「どうやってJavaScriptのコードをフルオンチェーンNFTにするのか」という点を中心に、解説していきます。

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

STEP
ROSESの仕組みをおさらい
STEP
シンプルなNFTを作ってみよう (Sample1)
STEP
ランダム要素を追加してみよう (Sample2)
STEP
テクスチャを貼ってみよう (Sample3)
STEP
大きなテクスチャを使ってみよう (Sample4)

本記事が、「Three.jsを使ったフルオンチェーンNFTの制作方法」などについて理解したいと思われている方にとって、少しでもお役に立てれば幸いです。

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

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

目次

ROSESの仕組みをおさらい

出典:OpenSea

ROSESについてはこちらの記事で詳しく解説していますが、要点だけ簡単に説明すると以下の仕組みで作られています。

  • メタデータのanimation_urlでHTMLを返す
  • Three.jsはgzipで圧縮しておき、JavaScriptのfflateライブラリを使って展開する
  • データコントラクトを使ってガス代を安くする

ROSESで使われているThree.jsやfflateのデータコントラクト、データコントラクトを扱うためのDataChunkCompilerコントラクトは、誰でも利用することができます。

nawoo

今回はこれらのコントラクトを使うことで、dom氏のいう「on-chain npm」を体験してみました。

とはいえ、dom氏がデプロイしたEthereumメインネットではガス代等の問題で気軽に試すことが難しいため、今回は同じデータコントラクトをGoerliテストネットにもデプロイしておきました。

コントラクトアドレス (Goerli)
FFlateDataChunk10x5707fFa3fE83303342786265a4fddBEAf61B2af4
FFlateDataChunk20xc2217600DeE61b6160cB07e32714CB67e4d3eEe0
ThreeDataChunk1 (※1)0x6a29761F7913Ae3848BF74df28fb1E9975480FC3
ThreeDataChunk20x42c5521eABdC14C50ED1967edbD424672187CBD4
ThreeDataChunk30xcfbE18E29269B649722174ebD7BDA871383009C3
ThreeDataChunk40xb4550ab1E9507ba5c91a52563c624Ae6463f80B2
ThreeDataChunk50x7e734f6daE766bb5A4dc7F1Ce957e39dC0Cf8f95
ThreeDataChunk60xCb835b9087c57223345D51A960e795f3b251cc0a
ThreeDataChunk70xBe8d24fdf071A5586f35bB120c1b8Bf2baD25e81
ThreeDataChunk80xA213524a7a155a422D09797F3F28E120cEC7E11e
ThreeDataChunk90x60e82790A1EC642EfDFBbD44ed9441C8cD938095
DataChunkCompiler0x139A815344FE764921468F2B4B8DA40b3b4b0618
DataChunkCompilerV2 (※2)0xe4c4400e11Bd8Ab2e4507B5aE4504E60E4A32433
  • 1) Three.jsのバージョンはr121です。OrbitControlsなどの外部モジュールを使う場合はバージョンに注意してください。
  • 2) DataChunkCompiler.solを一部修正してDataChunkCompilerV2.solとしてデプロイしました。 変更点は次の2点です。
    • SCRIPT_VAR関数でomitQuotes=falseの場合にvar "name=value";となってしまうバグを修正 (正しくはvar name="value";)
    • 遅延読み込み用の<script defer src="...">の追加 (今回の記事では未使用)

シンプルなNFTを作ってみよう (Sample1)

まずは本章では、物体が回転するだけのシンプルなNFTを作ってみます。

コントラクトの作成

今回は、ROSESのコントラクトを参考にしていますが、ROSESの場合はtokenURI関数のanimation_urlにHTMLを設定しています。

そしてこのHTMLには、以下5つのscriptとスタイルシートが含まれています。

出典:ethereumnavi.com/2022/10/01/what-are-roses/#index_id3

コントラクトのコードは以下です。

// Sample1.sol
contract Sample1 is ERC721("Sample1", "SAMPLE1"), Ownable {
    using Strings for uint256;

    IDataChunkCompilerV2 private compiler;
    address[9] private threeAddresses;
    string private constant STYLE_CODE = "%253Cstyle%253E%252A%257Bmargin (中略) %252Fstyle%253E";
    string private constant JS_CODE = "〜"; // ここに script5 (JavaScriptのメインコード)を埋め込む(後述)

    function setCompilerAddress(address newAddress) public onlyOwner {
        compiler = IDataChunkCompilerV2(newAddress);
    }

    function setThreeAddress(
        address chunk1, address chunk2, address chunk3,
        address chunk4, address chunk5, address chunk6,
        address chunk7, address chunk8, address chunk9
    ) public onlyOwner {
        threeAddresses[0] = chunk1;
        threeAddresses[1] = chunk2;
        threeAddresses[2] = chunk3;
        threeAddresses[3] = chunk4;
        threeAddresses[4] = chunk5;
        threeAddresses[5] = chunk6;
        threeAddresses[6] = chunk7;
        threeAddresses[7] = chunk8;
        threeAddresses[8] = chunk9;
    }

    function mint(uint256 tokenId) public onlyOwner {
        _mint(msg.sender, tokenId);
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        string memory threejs = compiler.compile9(
            threeAddresses[0], threeAddresses[1], threeAddresses[2],
            threeAddresses[3], threeAddresses[4], threeAddresses[5],
            threeAddresses[6], threeAddresses[7], threeAddresses[8]
        );
        string memory tokenIdStr = tokenId.toString();
        return
            string.concat(
                compiler.BEGIN_JSON(),
                string.concat(
                    compiler.BEGIN_METADATA_VAR("animation_url", false),
                    compiler.HTML_HEAD(),
                    STYLE_CODE,
                    string.concat(
                        compiler.BEGIN_SCRIPT_DATA_COMPRESSED(),
                        threejs,
                        compiler.END_SCRIPT_DATA_COMPRESSED()
                    ),
                    string.concat(
                        compiler.BEGIN_SCRIPT(),
                        compiler.SCRIPT_VAR("tokenId", tokenIdStr, true),
                        compiler.END_SCRIPT(),
                        compiler.BEGIN_SCRIPT(),
                        JS_CODE,
                        compiler.END_SCRIPT()
                    ),
                    compiler.END_METADATA_VAR(false)
                ),
                string.concat(compiler.BEGIN_METADATA_VAR("name", false), name(), "%20%23", tokenIdStr, "%22"),
                compiler.END_JSON()
            );
    }
}

DataChunkCompilerV2コントラクトには、HTMLやメタデータを簡単に作成する関数が用意されています。

例えば、

compiler.BEGIN_SCRIPT(),
compiler.SCRIPT_VAR("tokenId", tokenIdStr, true),
compiler.END_SCRIPT(),

と書くと、

<script>
var tokenId=1;
</script>

のようなHTMLが出力されます。

nawoo

後で説明しますが、2回URLエンコードされた状態で出力されます。

続いてtokenURI関数を見てみましょう。ここでscript1〜5とstyleを結合し、animation_urlを作成しています。

compiler.BEGIN_METADATA_VAR("animation_url", false),
compiler.HTML_HEAD(), // script1, 2 を作成
STYLE_CODE, // style を作成
string.concat(
    // script3 を作成 (Three.js本体)
    compiler.BEGIN_SCRIPT_DATA_COMPRESSED(),
    threejs, // データコントラクトから読み込んだ圧縮されたThree.js
    compiler.END_SCRIPT_DATA_COMPRESSED()
),
string.concat(
    // script4 を作成 (tokenId変数を定義)
    compiler.BEGIN_SCRIPT(),
    compiler.SCRIPT_VAR("tokenId", tokenIdStr, true),
    compiler.END_SCRIPT(),
    // script5 を作成 (メインコード)
    compiler.BEGIN_SCRIPT(),
    JS_CODE,
    compiler.END_SCRIPT()
),
compiler.END_METADATA_VAR(false)

Three.jsは、データコントラクトから読み出したものをcompiler.compile9関数で結合しています。

スタイルシートはSTYLE_CODE定数(constant)、JavaScriptのメインコード(script5)はJS_CODE定数で定義しました。

JavaScriptのメインコードを作成したら、後ほどJS_CODE定数に設定します。

JavaScriptメインコードの作成

続いては、JavaScriptのメインコードです。

nawoo

ここではThree.jsのチュートリアルを参考にして、物体(TorusKnot)を回転させてみました。

window.onload = () => {
  // シーン、カメラ、レンダラーの設定
  const size = { width: window.innerWidth, height: window.innerHeight };
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xffffff);
  const camera = new THREE.PerspectiveCamera(75, size.width / size.height, 0.1, 1000);
  camera.position.z = 4;
  camera.lookAt(0, 0, 0);
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(size.width, size.height);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // ライトとメッシュの作成と追加
  const light = new THREE.HemisphereLight(0xffffff, 0x202020, 1);
  const geometry = new THREE.TorusKnotGeometry(1, 0.3, 100, 16, 2, 3);
  const material = new THREE.MeshPhongMaterial({ color: 0x00ffff });
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(light, mesh);

  // resizeイベント
  window.addEventListener('resize', () => {
    size.width = window.innerWidth;
    size.height = window.innerHeight;
    camera.aspect = size.width / size.height;
    camera.updateProjectionMatrix();
    renderer.setSize(size.width, size.height);
    renderer.setPixelRatio(window.devicePixelRatio);
  });

  // アニメーション
  const clock = new THREE.Clock();
  const animate = function () {
    requestAnimationFrame(animate);
    const delta = clock.getDelta();
    mesh.rotation.x += delta;
    mesh.rotation.y += delta;
    renderer.render(scene, camera);
  };
  animate();
};

通常のThree.jsプロジェクトであればwindow.onloadに入れる必要はないのですが、 今回は圧縮したThree.jsを展開して<script>タグを動的に生成しています。

nawoo

この動的に生成した<script>が読み込まれてからでないと、Three.jsを使うことができません。

そのためにwindow.onloadを使って、Three.jsが読み込まれた後にメインコードが実行されるようにしています。

また、それ以外はThree.jsのチュートリアルと同じ基本的なコードです。

ライトはHemisphereLight、 メッシュはTorusKnotGeometryを使ったり、resizeイベントでカメラとレンダラーを再調整し、amimate関数でメッシュを回転させています。

nawoo

HTMLファイルを開くと、物体が回転しているのが確認できます。 (JSFiddleで見る)

埋め込み用JavaScriptコードの作成

作成したJavaScriptのメインコードは、コントラクトのJS_CODEにそのまま埋め込むのではなく、まずデータサイズを減らすためにコードを軽量化(Minify)してから、URLエンコードを2回行います。

nawoo

なぜ2回かというと、メタデータ(JSON)を作成するときにURLエンコードして、さらにtokenURIを作成するときに再度URLエンコードするからです。

A. <script>JavaScriptのコード</script>...
   ↓ メタデータを作成
B. {animation_url: "data:text/html,(ここにAをURLエンコードしたもの)", name: "..."}
   ↓ tokenURIを作成
C. data:application/json,(ここにBをURLエンコードしたもの)

では、まずJavaScriptコードを軽量化(Minify)します。

 Online JavaScript minifierなどのオンラインツールもありますし、スクリプトやコマンドを使う場合はUglifyJSが便利です。

nawoo

Minifyしたものが以下です。コメントや改行が削除され、変数名も短くなっています。

window.onload=()=>{const e={width:window.innerWidth,height:window.innerHeight},i=new THREE.Scene,t=...(略)

次に、URLエンコードします。

 URIエンコード変換ツールなどのオンラインツールでもいいですし、JavaScriptのencodeURIComponent関数で変換しても良いです。

window.onload%3D()%3D%3E%7Bconst%20e%3D%7Bwidth%3Awindow.innerWidth%2Cheight%3Awindow.innerHeight%7D...(略)

さらに、もう一度URLエンコードします。

window.onload%253D()%253D%253E%257Bconst%2520e%253D%257Bwidth%253Awindow.innerWidth%252Cheight%253A...(略)
nawoo

今回は手作業で行いましたが、何度も行う場合は一括変換するスクリプトを作っておくと便利ですよ。

では、作成したコードをコントラクトのJS_CODE定数に設定します。

// コントラクト (Sample1.sol)
string private constant JS_CODE = "window.onload%253D()%253D%253E%257Bconst%2520e%253D%257Bwidth%253A...(略)";

以上で、コントラクトは完成です。

デプロイ&ミント

デプロイしたら忘れずに、以下をおこなっておきましょう。

  • setThreeAddress関数で、Three.jsのデータコントラクトのアドレス設定
  • setDataCompiler関数で、DataChunkCompilerV2のアドレス設定
nawoo

そして、実際に筆者がミントしたものがこちらです。

出典:testnets.opensea.io/ja/assets/goerli/0x4645cc106437f4c745ac823053bc3986a7bba03c/1

実際にOpenSea画面で見てみると、3Dの物体が回転していることが確認できます。

ランダム要素を追加してみよう (Sample2)

さて、先ほどのNFTでは、何枚ミントしても全て同じ内容でした。

nawoo

でも、どうせなら1枚ごとに別々の内容にしたいですよね。

ということで本章では、先ほどのNFTを修正して、ミントする度にランダムに色や形が変わるジェネラティブNFTを作ってみましょう。

乱数生成について

JavaScriptには、ランダムな値(乱数)を生成するMath.random関数があるのですが、今回はこの関数は使えません。

なぜなら、JavaScriptはブラウザをリロードするたびに実行されますが、Math.random関数は実行される度に異なる値を返すため、 同じtokenIDなのにリロードするたびに内容が変化してしまうからです。

ジェネラティブNFTに必要なのは、tokenIDが同じであれば何度実行しても同じ乱数が得られる、「再現性のある乱数を生成する関数」です。

nawoo

しかし、残念ながらJavaScriptには「再現性のある乱数を生成する関数」が用意されていないので、自分で作る必要があります。

ここで、ROSESではどうやっているのか、実際のコードを見てみましょう。

const o = (o) => (void 0 !== o && (l = o % 2147483647) <= 0 && (l += 2147483646), ((l = (16807 * l) % 2147483647) - 1) / 2147483646);

軽量化(Minify)されているので読みにくいですが、この部分が乱数を生成する関数です。

ここでは、Park&Millerという疑似乱数生成法を使っています。

参考: Wikipedia – 線形合同法#Park&Miller

最初にo(seed)でseedを設定し、以後はo()で0.0〜1.0の乱数を生成します。

これにより、同じseedを与えると同じ乱数が得られるので、seedにtokenIDを設定すればブラウザをリロードしても内容が変わりません。

Randomクラスを作成

今回はROSESのコードを参考にして、Randomクラスを作ってみました。

nawoo

seedを指定して、さまざまなタイプの乱数を取得できます。

const MAX_INT32 = 2147483647;
class Random {
  constructor(seed) {
    if (seed <= 0) seed += MAX_INT32 - 1;
    this._value = seed;
    this.int(); // ※1
  }
  // 0 <= x < 2147483647 の整数を取得
  int() {
    this._value = (this._value * 48271) % MAX_INT32; // ※2
    return this._value;
  }
  // 0.0 <= x < 1.0 の浮動小数点を取得
  float() {
    return (this.int() - 1) / (MAX_INT32 - 1);
  }
  // min <= x < max の整数を取得
  intRange(min, max) {
    return Math.floor(this.floatRange(min, max));
  }
  // min <= x < max の浮動小数点を取得
  floatRange(min, max) {
    return min + (max - min) * this.float();
  }
  // true or false のブール値を取得
  boolean() {
    return this.int() % 2 === 0;
  }
  // 0x000000 <= x <= 0xffffff の色コードを取得
  color() {
    return this.intRange(0, 0x1000000);
  }
}

コンストラクタの引数でseedを指定し、乱数を生成するには int,float,intRange,floatRange,colorメソッドを使います。colorメソッドでは0x000000〜0xffffffの値が得られるので、Three.jsの色指定に使えます。

// 使用例
const rand = new Random(seed); // seedを指定
const a = rand.int(); // 0 <= a < MAX_INT32
const b = rand.float(); // 0.0 <= b < 1.0
const c = rand.intRange(1, 10); // 1 <= c < 10
const d = rand.intFloat(2.5, 3.5); // 2.5 <= d < 3.5
const e = rand.boolean(); // true or false
const f = rand.color(); // 0x000000 <= f <= 0xffffff 
  • 1) seedに1,2,3…というtokenIDを渡す場合、Park&Millerの計算式だと最初に生成する乱数は偏りが生じてしまいます。そこで、最初に生成した乱数は使わないように、コンストラクタ内で一度intメソッドを呼び出しておきます。
  • 2) ROSESでは乗数に16807を使っていますが、Wikipediaによると48271を使うほうが良いらしいので48271を使いました。

JavaScriptコード

Sample1のコードに、先ほどのRandomクラスを追加しました。

// ランダムな値を取得
const rand = new Random(tokenId);
const bgColor = rand.color(); // 背景色
const geomColor = rand.color(); // 物体の色
const p = rand.intRange(1, 9); // p: 1〜8の整数
const q = rand.intRange(1, 9); // q: 1〜8の整数
// (略)
scene.background = new THREE.Color(bgColor); // 背景色を設定
// (略)
const geometry = new THREE.TorusKnotGeometry(1, 0.2, 200, 32, p, q); // 形を設定
const material = new THREE.MeshPhongMaterial({ color: geomColor }); // 物体の色を設定

Randomクラスを使って、「背景色」「物体の色」「物体(TorusKnotGeometry)の形を決めるp,qの値」をランダムな値に設定します。

これらの値はtokenIDが同じであれば、何度実行しても同じになります。

コントラクトの作成

コントラクトは、Sample1と同じです。

nawoo

先ほどと同じように、JavaScriptコードをMinifyして2回URLエンコードしてから、コントラクトのJS_CODE定数に埋め込みます。

デプロイ&ミント

デプロイして、setThreeAddress関数とsetDataCompiler関数でアドレス設定しておきます。

今回は5枚ミントしてみました

  1. Sample2 #1
  2. Sample2 #2
  3. Sample2 #3
  4. Sample2 #4
  5. Sample2 #5
nawoo

ちゃんと1枚ごとに色や形が変化していますね。同じtokenIDであればリロードしても、色や形が変わったりしません。
Etherscanからフリーミントできるので、欲しい方はご自由にどうぞ。


最後に次章では、テクスチャを貼ってみたり、大きなテクスチャを使ってみるなど、よりマニアックな内容を「定期購読プラン」登録者向けにまとめています。ご興味あればご覧ください。

テクスチャを貼ってみよう (Sample3)


この続き: 1,922文字 / 画像6枚

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

まとめ

今回は、Three.jsを使ったフルオンチェーンNFTを4つ作ってみながら、ジェネラティブ・テクスチャ・外部データコントラクトの使用例などについて紹介しました。

本記事が、「Three.jsを使ったフルオンチェーンNFTの制作方法」などについて理解したいと思われている方にとって少しでもお役に立ったのであれば幸いです。

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

nawoo

Three.jsには多くの機能があるので、アイデア次第でもっと複雑なこともできると思います。ぜひ驚くようなフルオンチェーンNFTを作ってみてください!

なお、本記事のコントラクトやスクリプトはGitHubで公開しています。よければご参考ください。

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

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

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