Blog

GSAP + Three.js & glTF 3D拡大してくるアニメーション(前編)

このポートフォリオを作るにあたって、やりたかったことの一つに、「3Dを取り入れる」ということがありました。

もともとアニメーションが好きで、WEBサイトで面白い動きを見つけては「どうやってやってるんだろう…」と思っていましたが、
Three.jsで3Dが動かせる……じゃあ3Dってどうやって作るのかなと調べていくと

Blender
というソフトに出会いました。

3Dデータを作りたいと思い、「一からモデリングできるソフト」で探すと、意外と見つからない。
Adobe製品でどれかあるでしょ と探すと、まずでてくるのがAdobe Dimensionですが、
これもモデリング機能はないらしい。

現場で使われているようなソフトは、たとえばMAYAだと一ヶ月3万ちょいとかして

……高い!!

というわけで、無料で使えるBlenderを使ってみようとなったわけです。

最終的には作ったモデルをブラウザで動かしたいというのが目的だったので、
背景やライトの当て方に凝ったりするよりは、「基本的な部分がちゃんと書き出せて、Three.jsでアニメーションできる」ことを目指しました。

Blenderはすごく多機能でいろんなことができるんですが…
これが無料とはすごいです。

そんなわけで、
collageページのように、5つの画像をくしゃっと拡大するものを作りました。
それ以外の部分は、GreenSockのGSAPを使っています。

今回はBlenderでモデリングができたところから書いていきます。

長くなったので、前編と後編を分けました。

また、要点だけ抜き出したコードを載せていきます。
読み物感覚で見ていただければ幸いです。

※アナリティクスを見ているとこの記事が最近読まれ始めているので、2022年7月にリファクタを行い、この記事も更新しました。
変更箇所は、主にHTMLとGSAPの部分になるので主に後編のセクションです。

3Dオブジェクトを書き出す

blenderで作ったのは、平面にテクスチャを貼ったものです。くしゃっとなってその場で2倍のサイズにになるアニメーションを設定しています。

GSAPとの兼ね合い上、z軸(blender上ではy軸)を使うのはやめ、2倍のサイズにして前に出てくるような動きに見えるようにしました。

書き出し形式

できたものを書き出しする時に、
glTF 2.0 (glb / gltf)
という形式で書き出します。

Three.jsの公式にも

Where possible, we recommend using glTF (GL Transmission Format). Both .GLB and .GLTF versions of the format are well supported. Because glTF is focused on runtime asset delivery, it is compact to transmit and fast to load. Features include meshes, materials, textures, skins, skeletons, morph targets, animations, lights, and cameras.

https://threejs.org/docs/#manual/en/introduction/Loading-3D-models

訳:
可能であれば、glTF(GL Transmission Format)の使用をお勧めします。このフォーマットは、.GLBと.GLTFの両方がサポートされています。glTFはランタイム・アセットの配信に重点を置いているため、送信がコンパクトで、読み込みも速いのが特徴です。機能としては、メッシュ、マテリアル、テクスチャー、スキン、スケルトン、モーフターゲット、アニメーション、ライト、カメラなどがあります。

と記載があり、Blenderはこのフォーマットで書き出すことができます。

glbとgltfの違い

glb形式はファイルが一つにまとまっていて、gltf形式は下記のような複数ファイルから成ります。

  • .gltfファイル (glTFの主体をなす、概要としてのメタ情報が記述されたJSONファイル。他ファイルへの参照情報も含む)
  • .binファイル (頂点データやアニメーションデータなどが格納されたバイナリファイル)
  • 各種テクスチャ画像ファイル
  • シェーダーファイル(GLSLファイルなど)

どちらでもThree.jsで動かせるし、glbの方がファイルが一つにまとまっててよいのですが、今回はgltfを選択しました。
理由は、同じ動きをテクスチャだけ変えて5パターン作りたかったからです。

3Dファイルの下準備

今回シェーダーファイルはないので、

  • .gltfファイル
  • .binファイル
  • 各種テクスチャ画像ファイル

の3種類になりますが、.binファイルは頂点データなどが格納されているもの、つまり5つとも同じものなので、1ファイルを使い回します。

gltfファイルとbinファイルをどこかに置いておきます。

  • photo.gltf
  • photo.bin

gltfをテキストエディタで開くと、imagesのパスがあります。

    "images" : [
        {
            "mimeType" : "image/jpg",
            "name" : "photo-1",
            "uri" : "../img/photo-1.jpg"
        }
    ],

ここが違うと、nuxt generateした後にimageが読み込まれないというエラーが出て画像が表示されなかったので、気をつけましょう。(制作中は問題なかったのでハマった)

全体像

クリックして画像を拡大する前に、ある程度必要なものを作っておきます。
最初は、クリックしてからレンダラーを作成、gltfファイルをロード、シーンとカメラを作ってそれぞれcanvasを5つ分用意し…と、
クリック後にめちゃくちゃ処理を詰め込んだものを書いてみましたが、

おもっ!!!!!

となりました。予想はできていた…。

よく、SPAなんかで、ページが切り替わるたびに後ろで3Dのオブジェクト(canvas)が動く・・というのを見ますが、
仕組み的にはこれと同じというか、「クリックするまで何もしない」というのはやっぱり無理があります。
クリックで実行する処理は最小限に抑えないと、アニメーションがうまくいきません。
ロード処理とアニメーションスタートの処理はきっちり分けます。

最終的に、

  • canvasは一つだけ用意
  • フレームアニメーション手前の、gltfローディングとテクスチャローディング作業までは、あらかじめやっておく(ローディングには時間がかかるので)
  • レンダラー、シーン、カメラは1つだけ用意(使い回すものは1つでいい)
  • テクスチャを配列で5つ分もたせる(共通でないものはその分必要なので)

ということにしました。
今回は、Nuxt.jsを使っているので、canvasのコンポーネントを作り、そこでレンダラーやシーンを作成し、クリックするcollageのページでこれらを呼び出すことにしました。
Nuxt.jsの関わり方について詳しいことは省きますが、とりあえず、

  • canvasやシーンにcollageページからアクセスできる必要がある
  • dataやstateにThree.jsのオブジェクトを登録しない

という条件を考えた時に、windowにartworkGLというオブジェクトを登録して、そこに必要なものを入れていこうということになりました。

windowの前に、Vuex使うバージョンも考えてみましたが、VueComponentのdataやstateにThree.jsのオブジェクト(シーンとか)を入れるのはかなり非効率というか、
そもそもリアクティブである必要がないし、ただページコンポーネント間でやりとりしたい、それだけのためにdataやstateに登録するのは、何か違う気がします。

実際レンダラーをdataに入れてみたら、Vue devtoolsで「Unknown Component」と表示されました。
無理がある…

Three.jsオブジェクトを作る

必要なものは、

  • レンダラー
  • シーン
  • テクスチャ(5つ)
  • カメラ
  • ミキサー、モデル、アニメアクション

three.jsとGLTFLoaderを読み込む

export default class ArtworkGL{
  constructor(){
    THREE = window.THREE = require('three');
    require('three/examples/js/loaders/GLTFLoader');
  }

  〜
}

レンダラーを作成

  createRenderer(props){
    this.renderer = new THREE.WebGLRenderer({
      canvas: props.canvas,
      alpha: true
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(props.w, props.h);
  }

WebGLRendererに、描画するcanvasのdomElementを渡します。
alpha:trueはcanvasに透明度バッファが含まれているかどうか。
デフォルトはfalseなので、trueにしておかないと黒い空間が表示されます。

setPixelRatioはデバイスのピクセル比。window.devicePixelRatioでディスプレイの解像度に合わせてくれます。
setSizeでcanvasのサイズを決定。CSSでcanvasサイズをコントロールしているなら、このsetSizeはなくてもサイズが決定されます。
が、setSize()はデバイスのピクセル比を考慮して出力キャンバスのサイズを(幅、高さ)に変更し、(0、0)から始めてそのサイズに合うようにビューポートを設定するので、
…つまりはこれがないと、ピントが合わない可能性があります。3Dを作る時にカメラの位置とかサイズをかなりしっかり設計しないといけなくなります。(結構大きめで作ったら大丈夫だったりするんですかね…試してないですが…)

gltfをロード、シーン、カメラ、アニメーションアクションを作成

シーン、カメラ、アニメーションアクションを作ったら、window.artworkGLに入れておきます。

const IMAGE_L_WID = 800;//拡大時の最大幅
const ASPECT = 1.2;//カメラアスペクト比
const CAMERA_FOV = 30;//カメラfov
const CAMERA_POSITIPN_Z = 1540;//カメラposition_z

createSceneCameraAnimation(props) {
  this.scene = null;
  this.camera = null;

  const pr = () => new Promise((resolve) => {
    // シーンと光源を作成
    const scene = new THREE.Scene();
    const light = new THREE.AmbientLight(0xFFFFFF, 1);
    scene.add(light);
    this.scene = scene;
    if (!this.camera) {
      let zPosition = CAMERA_POSITIPN_Z;
      const window_h = document.documentElement.clientHeight;

      // ウインドウ高さが高い時はカメラをウインドウサイズに合わせてオブジェクトの大きさを固定 (z位置変更)
      if(window_h > IMAGE_L_WID / ASPECT / 0.9 && !mediaSp){
        const fovRad = (CAMERA_FOV / 2) * (Math.PI / 180);
        zPosition = (props.h / 2) / Math.tan(fovRad);
      }

      const camera = new THREE.PerspectiveCamera(CAMERA_FOV, props.w / props.h, 1, 5000);
      camera.position.set(0, 0, zPosition * 1.08);//掛け率調整
      
      this.camera = camera;
    }
    resolve(this.loadGL());
  })
  // gltfロード後に処理
  pr().then((e) => {
    // eには、returnしたフレームアニメに必要な「animeAction,model,mixer」がはいってる
    this.scene.add(e.model);
    this.anime = e
  })
}

カメラのところに色々分岐や掛け率の処理を入れていますが、スマホの時や画面の大きさによってカメラの位置を変えるためのもので、最終的に調整しているものです。

シーンの作成は

const scene = new THREE.Scene();

ライトの作成は

const light = new THREE.AmbientLight(0xFFFFFF, 1);
scene.add(light);

AmbientLightはシーン内全てのオブジェクトを均等に照らします。今回は影とかいらないのでこの光を採用。

カメラの作成は

const camera = new THREE.PerspectiveCamera(垂直視野角, アスペクト比, near ,far);

nearとfarは、カメラから見て、一定区間にあるオブジェクトだけをレンダリングするためのもの。

そして、アニメーションアクションを作成します。これがアニメーションに必要です。
とりあえず先にコードを。

loadGL() {
  const loader = new THREE.GLTFLoader();
  const url = '/3d/photo.gltf';
  let model = null;
  let mixer;
  let animeAction = [];

  const loadObj = () => new Promise((resolve, reject) => {
    loader.load(
      url,
      function (gltf) {
        model = gltf.scene;
        model.scale.set(100, 100, 100);
        model.position.set(0, 0, 0);

        mixer = new THREE.AnimationMixer(model);
        let animations = gltf.animations;

        if (animations && animations.length) {
          animations.forEach((animation,index) => {
            animeAction[index] = mixer.clipAction(animation);
            animeAction[index].setLoop(THREE.LoopOnce);
            animeAction[index].clampWhenFinished = true;
          })
        }
        resolve(animeAction);
      },
    );
  })
  const loadResult = loadObj().then(animeAction => {
    return { animeAction, model, mixer }
  })
  return loadResult
}

GLTFLoaderをよみこみ、

const loader = new THREE.GLTFLoader();

loadメソッドを使います。

loader.load('gltfのファイルパス',ロードが正常に完了した後に呼び出される関数)

第二引数の(関数の)引数で、ロードされたもの受け取れるので、それを使ってモデルやミキサー、アニメーションアクションを取得していきます…

が正直、この辺りの、ミキサーやアニメーションアクションの概念はまだよくわかっていません。
(アニメーションアクションという呼び名でいいのかすらわからない)
公式の説明では、

  • AnimationMixer → シーン内の特定のオブジェクトのアニメーションを再生するプレイヤー
  • AnimationAction → AnimationClipsに格納されているアニメーションの実行をスケジューリング

ということでした。
ミキサーもアニメーションアクションも、パーツごとに作成可能なもののようです。
ミキサーはプレーヤー、アニメーションアクションはアニメーションの設定(ループするか、何秒後から始めるかなど)ができる
…という感じで理解してます。

gltf.animationsには、配列でそれぞれのパーツのアニメーションが格納されています。
今回は、くしゃっとするシェイプアニメーションと、2倍になるscaleのアニメーションの2つがあるので、forEachでまわし、animeActionの配列を作ります。

if (animations && animations.length) {
  animations.forEach((animation,index) => {
    //Animation Actionを生成
    animeAction[index] = mixer.clipAction(animation);
    animeAction[index].setLoop(THREE.LoopOnce);
    animeAction[index].clampWhenFinished = true;
  })
}

ローディングが完了するまで待たないとanimeActionundefinedになります。
Promiseで縛って、それぞれを返却します。

return { animeAction, model, mixer }

テクスチャのローディング

テクスチャは5パターンあるので、ローダーを読み込み、テクスチャ(画像)をロードしておきます。

textureLoad(num) {
  const textureloader = new THREE.TextureLoader();
  artworkGL.texture = [];
  for (let i = 1; i <= num; i++) {
    artworkGL.texture[i] = textureloader.load(`/img/photo-${i}.jpg`);
    artworkGL.texture[i].flipY = false;
  }
}

これでアニメーションの準備ができました。

続きはこちら

おすすめの記事 recommend blog

新着 new blog

github