WebGLとHTMLで作成する3DのカバーフローUI

12

昔のmacOSやiTunesでは、カバーフローと呼ばれるUIがありました。カバーフローでは写真が奥行き方向に傾いて表示されていたりするなど、3Dの表現が使われています。

本記事ではHTMLとWebGLを作成する方法を解説します。WebGLのJSライブラリとしてThree.jsでデモを用意しています。本記事では初級者向けにステップ形式で重要な実装のポイントを説明し、カバーフローの実装方法を学べるようにしています。

デモの紹介

このデモはThrre.js r151と生のJavaScriptで作成しています。

STEP1. カバーフローの基本実装

画像ファイルの読み込み

カバーフローの実装に入る前に写真を事前に読み込むことにします。サンプルフォルダーのimgsフォルダーには画像ファイルを連番で0.jpg44.jpg まで用意しています。

3D空間への表示

画像ファイルは、Three.jsのマテリアルを作成し、THREE.PlaneGeometryクラスを使って平面のメッシュを作成します。

カードという名前でJavaScriptのクラスで作成しています。

/**
 * カバーフローのカード
 */
class Card extends THREE.Object3D {
  // (省略)

  /**
   * @param index {number}
   */
  constructor(index) {
    super();

    const texture = new THREE.TextureLoader().load("./imgs/" + index + ".jpg");

    // 上面

    // マテリアルの作成
    const material = new THREE.MeshLambertMaterial({
      map: texture,
    });

    const planeTop = new THREE.Mesh(
      new THREE.PlaneGeometry(ITEM_W, ITEM_H),
      material
    );
    this.add(planeTop);
    
    // (省略)
  }
}

変数cardはスライドの整列や移動に使うので再利用できるように配列に参照を保存します。

/**
 * 平面を格納する配列
 * @type {Card[]}
 */
const cards = [];

/** スライドの個数 */
const MAX_SLIDE = 44;

// (省略)

// Planeの作成
for (let i = 0; i < MAX_SLIDE; i++) {
  // カード
  const card = new Card(i);

  // 3Dシーンに追加
  scene.add(card);

  // 配列に参照の保存
  cards[i] = card;
}

スライドの整列

作成した平面のスライドを3D空間で並べてみましょう。スライドはx座標とz座標、rotation.yの角度を調整することで整列させることができます。下図のようにスライド画像を配置してみましょう。

xzrotation.yそれぞれのプロパティーについて分離して考えてみます。x座標はスライドの横方向の座標として扱います。カメラをセンターに配置し、中央に表示されるスライドの座標を0としてみます。中央に配置したスライドの左右はマージンを余分に取りますが、それ以外のスライドは等間隔に配置します。

カバーフローの場合、中央以外のスライドは斜めに傾いています。Three.jsではrotation.yプロパティーがY軸の回転となりますが、中央より左側のスライドはrotation.y-45度傾け、右側のものは+45度傾けます。z座標について中央のスライド画像は、他のスライド画像よりも手前に表示されるようにします。計算方法としては、中央のスライド画像のZ座標は0で、中央以外のスライド画像のZ座標を奥側に配置されるようにします。

この処理を抜粋したのが以下のコードとなります。idは中央のスライド画像のIDとなります。MAX_SLIDEはスライド画像の総数です。MARGIN_Xはスライド画像の横方向のマージンです。ITEM_Wはスライド画像の横幅です。

for (let i = 0; i < MAX_SLIDE; i++) {
  // 移動値を初期化
  let targetX = MARGIN_X * (i - id); // X座標の計算
  let targetZ = 0;
  let targetRot = 0;

  // 中央のスライド画像より左側のもの
  if (i < id) {
    targetX -= ITEM_W * 0.6; // 余白分ずらす
    targetZ = ITEM_W; // 奥側へ配置
    targetRot = +45 * (Math.PI / 180);
  }
  // 中央のスライド画像より右側のもの
  else if (i > id) {
    targetX += ITEM_W * 0.6; // 余白分ずらす
    targetZ = ITEM_W; // 奥側へ配置
    targetRot = -45 * (Math.PI / 180);
  }
  // 中央のスライド画像
  else {
    targetX = 0;
    targetZ = 0;
    targetRot = 0;
  }

  // (反射面は後述のため省略)
}

スクロールバーの設置

ユーザーインタフェースとしてスクロールバーを画面内に配置し、スクロールバーの値に応じてスライドを移動させるようにします。

スクロールバーはHTMLのinputタグのtype="range"属性値を使います。見た目はCSSで装飾しています。

<input type="range" id="rangeSlider" min="0" max="1" step="0.01" />

CSSのコードは一部抜粋

input[type="range"] {
  -webkit-appearance: none;
}

input[type="range"]::-webkit-slider-container {
  background: #000;
}

input[type="range"]::-webkit-slider-runnable-track {
  width: 30%;
  height: 12px;
  background: #000;
  border-radius: 10px;
  border: 1px #777 solid;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 10px;
  width: 30%;
  border-radius: 10px;
  border: 1px #000 solid;
  background: #777;
}

input[type="range"]:focus {
  outline: rgba(255, 255, 255, 0.2) solid 2px;
}

input[type="range"]:focus::-webkit-slider-runnable-track {
  background: #222;
}

スクロールバーのつまみを動かしたときにinputイベントが発生しますので、そのイベントをaddEventListener()メソッドで受け取るようにします。valueAsNumberプロパティーは0~1の値をとり、スクロールバーのつまみが左端にあるときは0を、右端にあるときは1の値となります。このvalueAsNumberプロパティーの値を使って、スライドを移動させるようにしておきます。

// インプット要素の制御
const elementInput = document.querySelector("input#rangeSlider");
elementInput.addEventListener("input", onInputChange);

// (省略)

/**
 * スクロールが動いたときのイベント
 */
function onInputChange() {
  const val = elementInput.valueAsNumber;
  // スクロールバーの値からページIDの計算
  const nextId = Math.round(val * (MAX_SLIDE - 1));
  // ページ遷移
  // (省略)
}

STEP2. 動きのブラッシュアップ

配置座標を計算する方法を紹介しましたが、アニメーションするように変更してみましょう。

モーショントゥイーンの実装

モーショントゥイーンの実装にはgsap(ジーサップ)というトゥイーンライブラリを利用することにします。

gsapはCDNで読み込むことができます。HTMLのheadタグ内に以下のコードを追加してください。

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.5/gsap.min.js"></script>

スライドの移動は、gsapを用いてアニメーションさせます。STEP1のスクリプトでは、スライドに座標や角度をも求めていましたが、これをgsapを使ってアニメーションするように書き直します。gsapでは次の書式で記述することでパラメーターを指定した時間でトゥイーンさせることができます。

gsap.to(対象のオブジェクト, { 
    変化させたいパラメーター: 目的の値, 
    duration: 秒数, 
    ease: イージングの指定,
    overwrite: true, // 上書き許可
  },
  );

スライドの移動をgsapで実装してみましょう。配置座標と角度を別々に動かしたいので、以下のようにpositionrotationを独立して制御しています。

// 対象のカードの参照
const card = cards[i];

// (省略)

// 配置座標を指定
gsap.to(card.position, {
  x: targetX,
  z: -1 * targetZ,
  duration: 1.8, // 1.8秒かけて移動
  ease: "expo.out", // 強めのイージングを指定
  overwrite: true, // 上書き許可
});

// 角度を動かす
gsap.to(card.rotation, {
  y: targetRot,
  duration: 0.9, // 0.9秒かけて移動
  ease: "expo.out", // 強めのイージングを指定
  overwrite: true, // 上書き許可
});

STEP3. 表現のブラッシュアップ

カバーフローでは、鏡面反射しているような表現をつけたいところです。

これを3D空間の上面(変数planeTop)の下側(Y座標)に配置します。垂直方向を反転して表示させたいので、メッシュとして回転させておきます。


/**
 * カバーフローのカード
 */
class Card extends THREE.Object3D {

  /**
   * @param index {number}
   */
  constructor(index) {
    super();

    const texture = new THREE.TextureLoader().load("./imgs/" + index + ".jpg");

    // (上面は先述のため省略)

    // 反射面
    const materialOpt = new THREE.MeshLambertMaterial({
      map: texture,
      transparent: true,
      side: THREE.BackSide,
    });
    materialOpt.opacity = 0.2;
    const planeBottom = new THREE.Mesh(
      new THREE.PlaneGeometry(ITEM_W, ITEM_H),
      materialOpt
    );
    planeBottom.rotation.y = 180 * (Math.PI / 180);
    planeBottom.rotation.z = 180 * (Math.PI / 180);
    planeBottom.position.y = -ITEM_H - 1;
    this.add(planeBottom);
  }
}

ライトと背景

演出上の調整としてTHREE.PointLightを追加し、中央のスライドが強調されるようにしています。中央のスライドにライトを当てており、周辺に行くほど光量が減衰するようにPointLightのプロパティーを設定しています。

// ライト
const pointLight = new THREE.PointLight(0xffffff, 4, 1000);
pointLight.position.set(0, 0, 500);
scene.add(pointLight);

背景にはグラデーションの画像を配置しています。幅・高さが大きめのTHREE.PlaneGeometryを使って、画像を貼り付けています。THREE.MeshBasicMaterialクラスは、ライトに影響をうけないマテリアルになります。背景はグラデーションを画像として用意しているので、ライトの影響を与えずに配置するためにこのマテリアルを使っています。

// 背景の生成
const meshBg = new THREE.Mesh(
  new THREE.PlaneGeometry(3000, 1000),
  new THREE.MeshBasicMaterial({
    map: new THREE.TextureLoader().load(URL_BG),
  })
);
meshBg.position.z = -500;
scene.add(meshBg);

ユーザービリティーの改善

次の対応を行うことで、ユーザービリティーが向上します。

  • キーボードのカーソルキー(上下左右)
  • マウスホイール

キーボードのカーソルキー対応はinputタグを活用します。inputタグは配置するだけで自動的にキーボード操作ができ、上下キーで数値を変えられます。画面表示時にはinputタグにフォーカスをあてておきます。

// インプット要素の制御
const elementInput = document.querySelector("input#rangeSlider");
elementInput.addEventListener("input", onInputChange);
elementInput.focus(); // フォーカスをあたえる

マウスホイール対応は、wheelイベントを監視します。inputタグのvalueAsNumber値を変更することで、スライドを移動させています。

// マウスホイール対応
window.addEventListener(
  "wheel",
  (event) => {
    elementInput.valueAsNumber += event.deltaY * 0.0005;
    onInputChange();
    event.preventDefault();
  },
  { passive: false }
);

以上が実装方法の説明となります。

コラム:別バージョンのデモ

このカバーフローのオリジナルデモは、筆者が2011年にFlashで作成していました。

自著「Stage3D プログラミング」で詳しく説明しています。

WebGLの時代にあわせて、2014年にWebGL版として、JSライブラリのAway3D TypeScriptと、Three.js r68を利用して2種類のデモを制作しました。以下の作例は、Away3D TypeScript版です。

WebGLとしてのJSライブラリAway3DやThree.jsへ移植したり、Three.js自体のバージョンアップに伴い、何度かコードを書き直しています。ウェブコンテンツを将来にわたって維持していくことの難しさを感じますね。

結果的に現在はThree.jsがデファクトスタンダードとなったので、本記事はThree.jsで解説しました。

まとめ

3D表現をユーザーインタフェースに使う作例として紹介しました。Three.jsを学ぶ教材としてお手頃なテーマなので、学びはじめの方はぜひ活用してほしい作例です。

Three.jsを使いこなせばさまざまな表現を作ることができます。ぜひ、Three.jsを使ってみてください。

※この記事が公開されたのは9年前ですが、11か月前の2023年5月に内容をメンテナンスしています。

池田 泰延

ICS代表。筑波大学 非常勤講師。ICS MEDIA編集長。個人実験サイト「ClockMaker Labs」のようなビジュアルプログラミングとUIデザインが得意分野です。

この担当の記事一覧