[Astro] #161 過去作品ギャラリー — Three.jsのカードを扇とリストで並べ、拡大するとサムネが動画になる
はじめに
前回(#160)は、トップページに置いた WINAMP 風プレイヤーでした。
[Astro] #160 WINAMP X2 — トップページに置いたWINAMP風プレイヤーと、VRMのダンス連動(自作.vrmaにも対応) // PROTOCOL.LAIN
トップページに、WINAMP 風の音楽・動画プレイヤーを作りました。メイン・EQ・プレイリスト・VIDEO の 4 枚の窓は独立してドラッグでき、窓同士は WINAMP と同じように吸着してくっつき、メインを動かすと一緒に動きます。
lain-lab.com今回は、過去に作った作品を一覧する ギャラリーページ(/works) です。
作品が増えるにつれて、過去の作品がツールや記事の中に埋もれてきました。「昔こんなのを作った」を見せる場所がなかったので、一覧のページを作ることにしました。
3D の作品が多いので、一覧も Three.js のカードにしました。実装は Astro のページ 1 枚と、works.json です。
要点は次の 3 つです。
- カードを扇状に並べて回す UI(扇とリストの切り替え)
- 拡大すると、静止画が動画に切り替わる
- サムネと動画を、自作の撮影ツールで撮る
以降は、実装の全体を簡単に残しておきます。
完成画面
リストモード
拡大して動画になる
操作動画
できることは次のとおりです。
- 扇モード:ドラッグで回す。止まったカードがめくれて表になり、説明が出る
- リストモード: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)- カードの下に、大きな床(
PlaneGeometry80×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 のアプリにする ことを考えています。枠の中だけを撮る、静止画と動画の撮影ツールです。