見出し画像

【WebGL演習】若手エンジニアによる3Dアニメーションへの挑戦

こんにちは!早いもので、もう年の瀬ですね。
みなさん、今年もお疲れ様でした。

コロナ禍に入社して早2年半。私は、Webの面白さに魅了され、もっと知りたい、もっともっと!と学んできたフロントエンドエンジニアの清瀬です。

「ちょっとnote書いてみてよ」の一言でnoteを書くことになりまして、うんうん悩んだ末、最近もう一度WebGLの勉強をし始めたので、

  • WebGLの勉強で制作したデモのご紹介とその解説

  • WebGLを学び始めて「初っ端からつまづいた話」

の2点を書くことにしました。
知らないことが多いゆえ、大なり小なり「へーそうなんだ!」のオンパレードで、デモを作っている時は非常に楽しかったです。
それではお付き合いください。


概要

今回紹介するデモは、パーティクル(粒子)が集まり、テキストやイラストを構成するアニメーションです。ライブラリとして、3DにはThree.js、アニメーションにはGSAPを使用しています。

コードの全容とデモはこちらです

以下の図のように、散らばったパーティクル(粒子)がそれぞれ移動して、任意の図形のライン状に整列します。

任意の図形に、横向きS字型のSVGを使って、シンプルなアニメーションのサンプルを作りました。animation Play▷を押すと、パーティクル(粒子)がそれぞれ移動する動きを見ることができます。

SVGのpath要素から頂点座標を計算するのが、このアニメーションのキモです。SVGLoaderを使って、図形を構成する座標を作成する方法が一般的ですが、今回は仕組みから理解するために、愚直に頂点座標を計算します。
また、実務では処理速度を上げるための工夫を行いますが、解説用デモのため、コードは極々シンプルに書くよう心がけて実装しています。


Step1 - SVGのpath要素を元に、頂点を計算する

アニメーションの終点となる頂点から作成します。

まずは、path要素上のポイントのxy座標を取得します。

// 1-1) path要素上のポイントのxy座標を取得
const pathPoints = [];

this.currentSvg.querySelectorAll("path").forEach((path) => {
  for (
    let offset = 0;
    offset < path.getTotalLength(); // path要素の全長を取得
    offset += 1 / this.params.density // もし密度densityが2だったらoffsetは、[0, 0.5, 1, 1.5, 2,...]
  ) {
    const point = path.getPointAtLength(offset); // path要素に沿ったポイントのxy座標を取得
    // console.log(point);
    pathPoints.push(point);
  }
});

SVGのpath要素を全て取得し、
0からpath要素の長さに達するまで、for文でループ。
path.getPointAtLength(offset)で、
path要素上のポイントのxy座標を取得します。

以下は、console.log(point)の結果です。xy座標が返ってくることがわかります。

次に、取得したxy座標を元に「path要素に沿った頂点」basePositionを作成します。

const viewBox = this.currentSvg.viewBox.baseVal;

pathPoints.forEach((pathPoint) => {
	// 1-2) 座標を元に、「path要素に沿った頂点」を作成
  const basePosition = new THREE.Vector3(
		// 矩形の中心を原点とする座標に計算し直す
    pathPoint.x - viewBox.width / 2,
    -pathPoint.y + viewBox.height / 2,
    0
  );

// ...
});

Vector3を使って、
2DのSVGの座標から、3D空間内の頂点に変換処理を行います。


Step2 - path要素に沿った頂点を元に、拡散した頂点を作成する

終点となる図形の頂点ができたので、
これを元に、今度はアニメーションの始点となる頂点を作成していきます。

pathPoints.forEach((pathPoint) => {
  // 1-2) 座標を元に、「path要素に沿った頂点」を作成
	// ...

  // 2) 「拡散した頂点」を作成
  const diffusion = 250; //拡散(爆発)力
  const diffusePosition = new THREE.Vector3(
    basePosition.x + (Math.random() - 0.5) * diffusion,
    basePosition.y + (Math.random() - 0.5) * diffusion,
    basePosition.z + (Math.random() - 0.5) * diffusion
  );

	// ...
});

Step1で作成した頂点に対して、
-0.5以上0.5未満のランダムな値に250を掛けて足し合わせ、
「拡散する頂点」diffusePositionを作成します。

これで、SVGのpath要素から、

  • アニメーションの終点となる「path要素に沿った頂点」

  • アニメーションの始点となる「拡散した頂点」

の2種類を作成することができました。


Step3 - 頂点を移動させる

最後に、頂点ひとつひとつを始点(diffusePosition)から終点(basePosition)へとアニメーションさせて完成です。

// 3) アニメーションの実行
/*
- 左から徐々に頂点が集まってくるアニメーションにするためのdelay計算

X成分だけを抽出する。
矩形の中心を原点とする座標になっているので、左端原点に計算しなおし、頂点のx座標を出す。
viewBoxの横幅に対する頂点のx座標の割合を計算する。
*/
const delay = (basePosition.x + viewBox.width / 2) / viewBox.width;

this.particleTimeline.to(
	diffusePosition,// 始点から
	{
		// 終点へ
	  x: basePosition.x,
	  y: basePosition.y,
	  z: basePosition.z,
	  ease: "Power3.easeOut",
	  duration: 0.6,
	},
	delay * 0.7
);

解説したコードの全体の関数はこちら。

#updateGeometry() {
  this.baseVertices = [];
  this.diffuseVertices = [];
  this.geometry.dispose();
  this.geometry = new THREE.BufferGeometry();

  // 1-1) path要素上のポイントのxy座標を取得
  const pathPoints = [];

  this.currentSvg.querySelectorAll("path").forEach((path) => {
    for (
      let offset = 0;
      offset < path.getTotalLength(); // path要素の全長を取得
      offset += 1 / this.params.density // もし密度densityが2だったらoffsetは、[0, 0.5, 1, 1.5, 2,...]
    ) {
      const point = path.getPointAtLength(offset); // path要素に沿ったポイントのxy座標を取得
      // console.log(point);
      pathPoints.push(point);
    }
  });

  const viewBox = this.currentSvg.viewBox.baseVal;

  pathPoints.forEach((pathPoint) => {
		// 1-2) 座標を元に、「path要素に沿った頂点」を作成
    const basePosition = new THREE.Vector3(
      // 矩形の中心を原点とする座標に計算し直す
      pathPoint.x - viewBox.width / 2,
      -pathPoint.y + viewBox.height / 2,
      0
    );

    // 2) 「拡散した頂点」を作成
    const diffusion = 250; //拡散(爆発)力
    const diffusePosition = new THREE.Vector3(
      basePosition.x + (Math.random() - 0.5) * diffusion,
      basePosition.y + (Math.random() - 0.5) * diffusion,
      basePosition.z + (Math.random() - 0.5) * diffusion
    );

    // 3) アニメーションの実行
    /*
    - 左から徐々に頂点が集まってくるアニメーションにするためのdelay計算

    X成分だけを抽出する。
    矩形の中心を原点とする座標になっているので、左端原点に計算しなおし、頂点のx座標を出す。
    viewBoxの横幅に対する頂点のx座標の割合を計算する。
    */
    const delay = (basePosition.x + viewBox.width / 2) / viewBox.width;
    this.particleTimeline.to(
      diffusePosition, // 始点から
      {
        // 終点へ
        x: basePosition.x,
        y: basePosition.y,
        z: basePosition.z,
        ease: "Power3.easeOut",
        duration: 0.6,
      },
      delay * 0.7
    );

    this.baseVertices.push(basePosition);
    this.diffuseVertices.push(diffusePosition);
  });

  this.#updateColor();
  this.particle.geometry = this.geometry;
}
// 毎フレームごとに実行
update() {
  // 頂点位置を更新
  this.geometry.setFromPoints(this.diffuseVertices);

	// ...
}

デモではこの仕組みを元にして、シフトブレインのロゴ以外も作成しました。右上のControls内の「SVG」で色々選べます。

どの子が出るかはお楽しみ〜〜☆


実務での制作の流れ

今回は勉強のため、 何を制作するか私の方で自由に決めてからデモを作成しましたが、シフトブレインでの実際の業務では、GUIを使ってパラメータを設置し値を変えられるようにした上で、 デザイナーとエンジニアが相互に連携をとりながら制作を進めます。(今回のデモも色や画像を変えられるので、色々と遊んでみてくださいね!)
デザイナーにはある程度の自由度を持たせた状態で見てもらい、最後はデザイナーの感性に任せて、完成にまで落とし込みます。

これまでの事例をもとに制作の流れを紹介している記事もあるので、こちらもご覧ください!


3Dプログラミングと数学

3Dプログラミングを勉強し始めると、

// 値の反転
1-progress

// スクリーンの中心を原点とする座標に計算し直す
(カーソルの位置.X / ウィンドウ幅) * 2.0 - 1.0

// 矩形の中心を原点とする座標に計算し直す
position.x - オブジェクトの幅 / 2

// 0を中心とした正負に均等に値を分散させる
Math.random() - 0.5

// 円運動
target.position.x = Math.cos(angle) * 半径;
target.position.z = Math.sin(angle) * 半径;

// ベクトルの単位化
let vector = [x,y];
let length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1]);
vector[0] /= length;
vector[1] /= length;

上記のように、
「お決まりの形の式」が至る所によく出てきます。
意味を知っている人が見れば、なんてことない簡単な式ですが、初心者には少し違和感があるかもしれません。
私も最初は、
「一体これはどういう意図だろう?」と、いちいち立ち止まりました。

値を累乗したり、三角関数・ベクトルの計算が出てくるたび、
「何だかややこしそうな計算をしている……」と敬遠していましたが、
単に、
コントラストをつけるために累乗しているだけ、
向きを知りたいからベクトルの性質を使っているだけ、
と気がつくことができてからは、複雑に見えていたコードの意図が急に分かるようになりました。

「3Dプログラミングを学ぶなら、とにかく数学を最初から学び直さなくちゃ!」と力みすぎる必要はありませんが、やりたいことを表現するために各分野の概念の理解をしておくことは大切なのかもしれません。

概念が理解できていれば、
例えば、数多あるGLSLのビルトイン関数を学習するとき、

  • 正方形の頂点を円形にしたいから、長さがわかるdistance()を使う

  • 頂点の中心の色を濃くしてコントラストをつけたいから、累乗してくれるpow()を使う

  • 色の境目をはっきりさせたいから、しきい値を返してくれるstep()を使う

など、やりたいことが先にあり、それを表現するためにこれを使う、というようにその特性を活かした最適な解を選べるようになります。

なるほど!と、仕組みがわかった時、
それはそれは感動してノートに書き留めるのですが、数週間後に見返すと至極当然のことを書いていて、急に恥ずかしくなることがあります。
このnote記事も、きっとすぐにそうなることでしょう(笑)。
3Dプログラミングは最初の理解がとても難しいですが、使いこなすために、基本の理解をおざなりにせず、ひとつずつ確実にクリアしながら、レベルアップし続けていきたいと思います。


おわりに

私は、憧れの先輩に追いつけ追い越せ……毎日奮闘中のエンジニアです。 WebGLを自由自在に使いこなす先輩方の大きすぎる背中を日々必死に追いかけています。社内に次々と上がってくる神がかったサイトを見る度、
「こんなん追いつけるわけない!」と内心ひとり吠えつつも、
20代で憧れの人たちに出会えたことは、この上なく幸せなことです。

シフトブレインは現在、完全フルリモート体制になっています。
画面を睨めっこしながら顔を付き合わせての指導は
残念ながら今は望めませんが、
それでも、
何としても教えよう・育てようと、あの手この手を使って上に引き上げようとしてくれる先輩方の熱意にこれからも全力で食らいついていく所存です。

最後に、この記事を書くにあたって、
何度も何度も何度も、相談に乗ってくれたチームのメンバーへ
感謝の気持ちを伝えて終わります。
ありがとうございました。

では。


KIYOSE Sayaka


▼ 採用情報
シフトブレインでは、新しい仲間を募集しています。全社フルリモートのため、全国からのご応募をいただけます。
募集の詳細は、Wantedlyにてご覧ください!

▼ SHIFTBRAIN GOODS STORE


Edit: YASUDA Yuhei, SAKA Mihoko