[Astro] #161 過去作品ギャラリー — Three.jsのカードを扇とリストで並べ、拡大するとサムネが動画になる

[Astro] #161 過去作品ギャラリー — Three.jsのカードを扇とリストで並べ、拡大するとサムネが動画になる

はじめに

前回(#160)は、トップページに置いた WINAMP 風プレイヤーでした。

今回は、過去に作った作品を一覧する ギャラリーページ(/works) です。

作品が増えるにつれて、過去の作品がツールや記事の中に埋もれてきました。「昔こんなのを作った」を見せる場所がなかったので、一覧のページを作ることにしました。

3D の作品が多いので、一覧も Three.js のカードにしました。実装は Astro のページ 1 枚と、works.json です。

要点は次の 3 つです。

  1. カードを扇状に並べて回す UI(扇とリストの切り替え)
  2. 拡大すると、静止画が動画に切り替わる
  3. サムネと動画を、自作の撮影ツールで撮る

以降は、実装の全体を簡単に残しておきます。

完成画面

過去作品ギャラリー — 扇モード

リストモード

過去作品ギャラリー — リストモード

拡大して動画になる

拡大表示 — サムネイルが動画に切り替わる

操作動画

操作動画 操作動画

できることは次のとおりです。

  • 扇モード:ドラッグで回す。止まったカードがめくれて表になり、説明が出る
  • リストモード:4 列のグリッド。選んだカードの説明が出る
  • 選択済みのカードをもう一度押すと、画面の左半分に拡大(スマホは下寄り)して、動画が流れる
  • 拡大中は、CLOSE ボタンか Esc で戻る
  • 操作がしばらく無いと、扇が自動で次の作品へ進む
  • 作品の説明、→ OPEN(作品ページ)、関連記事のボタン
  • トップページのウィンドウ(iframe)の中でも使える
  • PC は横長、スマホは縦長のレイアウトに切り替わる

構成

src/
├─ pages/works.astro        ページ本体(Three.js のスクリプトもここ)
└─ data/works.json          作品のデータ
public/images/
├─ thumbs/                  サムネ(.webp)と動画(.mp4)
└─ card-backs/tarot.webp    カードの裏面

作品のデータは、works.json に 1 件ずつ書きます。

{
  "id": "rubikscube",
  "title": "Rubiks Cube",
  "desc": "Next.js + R3F の学習目的で…",
  "url": "/gallery/RubiksCube",
  "thumb": "/images/thumbs/RubiksCube.webp",
  "video": "/images/thumbs/rubikscube.mp4",
  "articles": [
    { "label": "記事 Astro #38", "url": "/posts/astro-38-r3f-webxr-rubiks-cube" }
  ]
}

作品を足すときは、この 1 件と、サムネ・動画のファイルを置くだけです。カードの枚数 N は works.length から決まります。

articles は無くても動きます。ある場合だけ、情報パネルに → 記事名 のボタンが並びます。

カードを作る

canvas をテクスチャにする

カードは PlaneGeometry に、canvas から作ったテクスチャを貼ったものです。表と裏は、別のメッシュ(裏は Y 軸に 180 度回転)にしています。

const group = new THREE.Group();
const front = new THREE.Mesh(geo, mat);      // 表: サムネ + ピンクの枠
const back  = new THREE.Mesh(geo, backMat);  // 裏: 全カード共通
back.rotation.y = Math.PI;
group.add(front, back);

表は 512×768 の canvas に、サムネ画像を全面に描いて、6px のピンクの枠線(#ff5fa2)を足しています。画像が読み込まれるまでは、番号入りのグラデーションが出ます。

裏は 1 枚の canvas を全カードで使い回しています。読み込みのたびに、PROTOCOL.LAIN の柄とタロット風の画像のどちらかが選ばれます。

なぜ canvas か

あとで動画を同じ枠線つきで出したかったからです。動画を VideoTexture にして貼ると、枠線を別に描く必要があります。canvas に画像も動画も枠線も描いておけば、テクスチャは 1 枚のままです。この話は、後の「動画に切り替える」で出てきます。

扇に並べる

角度で並べる

扇は、画面の隅(PC は左下、スマホは下の中央)を中心にした円の上に、カードを等間隔で並べたものです。

function fanPose(i, L, o) {
  const d = wrapDiff(i - rot);        // 先頭からの差(最短経路)
  const a = L.a0 + d * STEP;          // 扇の上の角度
  const f = smooth(cards[i].flip);    // 0 = 裏向き、1 = 表向き
  const r = L.r + f * 0.4;            // めくれると少し外に出る
  o.pos.set(L.cx + r * Math.cos(a), L.cy + r * Math.sin(a), d * 0.02 + f * 0.8);
  qa.setFromAxisAngle(Z, a - Math.PI / 2);          // 中心から放射状に向ける
  qb.setFromAxisAngle(Y, Math.PI * (1 - f));        // めくり
  o.quat.copy(qa).multiply(qb);
  o.s = 1 + f * 0.15;
}

rot は小数も取れる連続値で、「いま先頭にいるカードの番号」を表します。STEP は 2π / N で、カード 1 枚ぶんの角度です。wrapDiff で、カードとの差を -N/2 … N/2 に折り返しているので、どのカードも一番近い向きに回ります。

止まったカードは、flip が 0 から 1 に近づいて、裏から表にめくれます。このとき半径が少し伸びて、手前に出るので、「引き抜いた」感じになります。

臨界減衰のバネで回す

回転 rot は、目標 target に向かうバネで動かしています。

const dist = target - rot;
const t = THREE.MathUtils.clamp(Math.abs(dist) / 3.0, 0, 1);
const k = THREE.MathUtils.lerp(DECEL_K, ACCEL_K, t);   // 25 → 2
const c = 2 * Math.sqrt(k);                            // 臨界減衰
vel += (k * dist - c * vel) * dt;
vel = THREE.MathUtils.clamp(vel, -MAX_VEL, MAX_VEL);
rot += vel * dt;

減衰係数を 2√k にすると、行き過ぎずに最短で止まります(臨界減衰)。バネの強さ k は、目標が遠いときは弱く(加速をゆるやかに)、近づくと強く(きびきび止める)しています。目標まで遠い(たとえば一周する)ときの暴走は、MAX_VEL で頭打ちにしています。

ドラッグで回す

ポインタの位置を、扇の中心から見た角度に直して、前のフレームとの差を STEP で割って rot に足しています。

const a = Math.atan2(p.y - L.cy, p.x - L.cx);
let da = a - lastAng;
da = Math.atan2(Math.sin(da), Math.cos(da));   // -π..π に折り返す
rot += -da / STEP;

指を離したときの速度で target = Math.round(rot + vel * 0.25) として、一番近いカードに止まらせます。

自動送り

操作が 4 秒(IDLE_RESUME)無く、扇が止まっているときは、めくれた状態で 3.2 秒(AUTO_INTERVAL)待ってから、いったん閉じて次へ進みます。

if (cards[current].flip < 0.01) { isClosing = false; target += N + 1; }

N + 1 は、「一周して、さらに 1 枚」です。全部のカードがぐるっと回ってから次の作品に止まるので、見ていて楽しい動きになります。拡大中と prefers-reduced-motion のときは、止めています。

リストに切り替える

扇とリストの切り替えは、カードごとの mix(0 = 扇、1 = リスト)を動かして、位置と回転を補間しています。

fanPose(i, L, fanP);
listPose(i, listP);
c.group.position.lerpVectors(fanP.pos, listP.pos, m);
c.group.position.z += Math.sin(m * Math.PI) * 1.5;   // 移動の途中だけ手前に浮かす
c.group.quaternion.slerpQuaternions(fanP.quat, listP.quat, m);
c.group.scale.setScalar(THREE.MathUtils.lerp(fanP.s, listP.s, m));

位置は lerp、回転は slerp(クォータニオンの補間)です。途中で z を sin で手前に持ち上げているので、カード同士がめり込まずに、ふわっと入れ替わります。カードごとに MIX_STAGGER(0.03 秒)ずつ開始をずらして、順番に動くようにしています。

リストは 4 列のグリッドで、PC は画面の左半分、スマホは下寄り(上は情報パネル)に置いています。

拡大表示

選択済みのカードをもう一度押すと、zoom が 0 から 1 へ進んで、そのカードだけが拡大の位置に移ります。

function zoomLayout() {
  const z = 3, k = (CAM_Z - z) / CAM_Z;
  const Hz = H * k, Wz = W * k;     // z = 3 の面で見える高さと幅
  return portrait
    ? { x: 0, y: -Hz * 0.16, z, s: Math.min((Hz * 0.5) / CH, (Wz * 0.88) / CW) }
    : { x: -Wz * 0.25, y: 0, z, s: Math.min((Hz * 0.8) / CH, (Wz * 0.44) / CW) };
}

カメラからの距離が変わると、同じ画面幅でも 3D 空間での幅が変わります。そのため、拡大先の z で見える広さを、(CAM_Z - z) / CAM_Z の比で計算してから、画面のどのくらいを占めるかを決めています。

拡大中は、他のカードの明るさを 1 - 0.7 * zp まで落として、拡大したカードが引き立つようにしています。カードの操作は受け付けず、CLOSE か Esc で戻ります。

暗い部屋のピンスポット

背景は暗くして、スポットライトの当たっているところだけ、カードが浮かぶようにしました。

  • AmbientLight(0.5):部屋の明るさ
  • SpotLight:光の円錐の半角は、通常 0.17、拡大中は 0.7(penumbra は 0.7)
  • カードの下に、大きな床(PlaneGeometry 80×80、z = -0.6)を敷く:影は落とさず、光だまりだけが浮かぶ
  • カードのマテリアルは MeshStandardMaterial(光が当たると明るくなる)

スポットの狙う先は、状況で変えています。

if (zoomTarget || zoom > 0.001) spotGoal.copy(cards[zoomIdx].group.position);   // 拡大中は拡大カード
else if (mode === 'list')       spotGoal.copy(cards[current].group.position);    // リストは選択中
else spotGoal.set(L.cx + (L.r + 0.4) * Math.cos(L.a0), L.cy + (L.r + 0.4) * Math.sin(L.a0), 0.4);  // 扇は「めくれる場所」
spotTarget.lerp(spotGoal, 1 - Math.exp(-dt * 6));

扇では、スポットを「カードがめくれる場所」に固定しています。カードが回ってきて止まると、そこだけが明るく照らされます。

拡大カードだけ動画にする

拡大したカードのサムネイルを、mp4 に切り替えます。同時に再生するのは 1 本だけです。

動画は <video> を作って再生し、新しいフレームが来るたびに、カードの canvas に描いています。

function updateVideo() {
  if (!vid) return;
  const { el } = vid;
  if (el.readyState < 2 || el.currentTime === vid.lastT) return;   // 新しいフレームだけ
  vid.lastT = el.currentTime;
  drawFront(g, vid.idx, el); tex.needsUpdate = true;
}

drawFront は、サムネ画像でも動画でも、同じように描ける関数にしてあります。動画のフレームを描いたあとに枠線を足すので、静止画のときと同じ見た目のままです。

止めるときは、メモリと通信を解放して、静止画に戻します。

function stopVideo() {
  const { idx, el } = vid;
  vid = null;
  el.pause(); el.removeAttribute('src'); el.load();   // 通信とメモリを解放
  drawFront(g, idx, stills[idx]);                     // 静止画に戻す
}
  • 動画は、拡大したときに読み込み始めます(14 本まとめて先読みはしない)
  • 拡大から戻るアニメーションが終わるまで、動画は止めません
  • 動画が読めなかったときは、静止画のままにします
  • prefers-reduced-motion と、通信量の節約モード(saveData)のときは、動画を使いません

動画は音なし(muted)です。サイズは、14 本で合計 10MB ほどです。

入口とウィンドウ表示

ウィンドウの中でも動かす

トップページのスタートメニューからは、ウィンドウ(iframe)の中に /works?embed として開いています。?embed が付いているときは、ヘッダーとフッターを隠します。

:global(html.is-embed) .gallery-stage { margin-top: 0; height: 100svh; }

普通のページでは、ヘッダーの高さぶん margin-top を取って、高さを calc(100svh - 80px) にしています。ウィンドウの中では、ヘッダーもフッターも無いので、そのままだと上下に黒い帯が出ます。上の 1 行で、ウィンドウいっぱいに広げています。

ヘッダーとスマホのメニュー

ヘッダーのナビにも [GALLERY] を足しました。スマホは幅が足りないので、ナビを隠して、ハンバーガーで開くメニューにしています。

サムネと動画を撮る

14 作品ぶんの、縦長のサムネと動画を撮る必要がありました。そのために、ブラウザの中で動く撮影ツール を作りました(/capture-station、検索には出さない設定)。

  • 枠の中だけを撮る:getDisplayMedia({ preferCurrentTab: true }) に、CropTarget.fromElement(枠) と track.cropTo() を組み合わせる(Region Capture)
  • 作品を iframe で枠の中に開いて、静止画(1 枚)と動画(15 秒)を撮る
  • WebGL の作品も撮れる

撮ったあと、動画は ffmpeg で縮めています。

ffmpeg -y -i in.mp4 -vf scale=512:768,setsar=1 -c:v libx264 -crf 28 -preset slow -an -movflags +faststart out.mp4

15 秒の撮影そのままだと 14MB ほどですが、512×768 に縮めて crf 28 にすると、1MB 前後になります。映像が激しい作品は、crf 33 まで上げました。

ハマりどころ

1. スタートメニューから開くと「UNKNOWN COMMAND」になる

start_menu.json に "action": "/works" と書いたところ、ウィンドウではなくターミナルに /works が渡って、エラーになりました。action は、ターミナルのコマンドとして扱われます。

{ "title": "GALLERY", "icon": "🃏", "width": 1000, "height": 700, "app": "/works" }

ページを窓で開くときは、app に書いて、width と height も指定します。

2. ウィンドウの中で、上下に黒い帯が出る

上で書いたとおり、ヘッダーとフッターを隠したのに、ページの高さの計算がそのままだったのが原因です。html.is-embed のときだけ、高さと margin-top を変えて直しました。

3. 動画が再生されない(ファイル名の大文字と小文字)

works.json に VideoWall.mp4 と書いていて、実際のファイルは videowall.mp4 でした。Windows では気づきませんが、Linux のファイルシステムは大文字と小文字を区別するので、読み込めませんでした。

video のパスは、作品の id(小文字)から作るようにして、揃えています。

4. スポットの位置が、扇とリストでずれる

スポットは、狙う位置から「左上手前」に置いていました。扇ではちょうど良かったのですが、リストに切り替えると、光だまりがカードの中心からずれて見えました。

斜めから当てているので、カードの位置が変わると、光だまりのずれが目立ちます。扇とリストで、光源のオフセットを別々に持たせて、モードの切り替えのときに滑らかに補間しています。

const SPOT_OFFSET_FAN  = new THREE.Vector3(3, 3, 11);
const SPOT_OFFSET_LIST = new THREE.Vector3(1.5, -0.8, 6);   // 正面に近づけてズレをなくす
currentOffset.lerp(targetOffset, 0.05);

5. 撮影した画像の下が切れる

撮影ツールの枠を 1024×1408 にしていたら、画面に収まらず、下が切れていました。Region Capture の出力は、画面に映っているピクセルそのままなので、枠がブラウザの表示領域に全部入っていないと切れます。枠を 880×1320 にして収めています。

6. JS で作った要素に、Astro の CSS が当たらない

関連記事のボタンは、JS で <a> を作っています。Astro の <style> は、書いたテンプレートの要素にだけ効く(スコープが付く)ので、JS で作った要素には当たりません。:global を付けて指定しました。

#info :global(a) { color: var(--accent); border: 1px solid var(--accent); }

まだ入れていないこと

  • 作品の検索や、カテゴリでの絞り込み
  • カードの裏面を、作品ごとに変える
  • 作品ごとの詳細ページ(今は、説明と記事ボタンだけ)
  • 動画の音声(音と連動する作品は、動画だけだと伝わりにくいので、気になっています)

おわりに

作ってみると、一番の収穫は、カードの動きのほうでした。扇の角度の計算も、バネでの回転も、リストへの補間も、Three.js の lerp と slerp の組み合わせで、小さな式の積み重ねです。

動画を入れたことで、作品の印象が大きく変わりました。静止画だけだと「どんな作品か」が分かりにくいのですが、動いているのを見ると、遊んでみたくなります。

撮影ツールは、使い捨てるのがもったいない出来になりました。ただ、ブラウザの中で動く以上、外のサイトは iframe で埋め込めず(X-Frame-Options などの制限)、撮れません。

次は、これを Windows のアプリにする ことを考えています。枠の中だけを撮る、静止画と動画の撮影ツールです。