パック開封の演出を three.js で作る:動画のコマ観察、キャンバスの罠、袋の質感、衣装がかぶらない抽選
に公開
カードパックを開ける演出を three.js で作った記録。プレイ動画を0.2秒ごとのコマで観察する方法、display:none でキャンバスが大きさ0になる罠、WebGLの上限16、袋の質感、衣装がかぶらない抽選。
目次
カードのパックを引いて開ける演出を、three.js で作りました。作り始めの質が低かったので、遊んだことのあるカードゲームのプレイ動画を0.2秒ごとのコマで観察して、流れを写し取るところから作り直しました。途中で、display:none のキャンバスは大きさ0になる、WebGL のコンテキストには上限16がある、パックの袋が紙に見える、といった罠にも当たりました。それぞれの原因と直し方、5枚の衣装がかぶらない抽選のコードを書きます。
作りたかったもの
欲しかったのは、箱からパックを選び、袋の封を切り、カードが1枚ずつ出てきて、最後に5枚を並べて見られる、という流れです。カードは、傾けると奥行きや光が動くカード(深度マップの記事、レア度の設計)を使います。最初の版は、既存の3D の袋(three.js の部品)とカードを組み合わせて作りましたが、「質が低い」という指摘を受けて、ポケポケの開封を確認して作り直しました。
動画を0.2秒ごとのコマで観察する
演出は、言葉で説明されても作れません。そこで、実況のないプレイ動画を yt-dlp で取得し、0.2秒ごとのコマを並べた一覧にして、別の目(別の評価者)に流れを読み取ってもらいました。動画は1秒でも5コマになるので、「光る」「寄る」「横に送る」といった動きの順番と長さが、コマの数から分かります。
読み取れた要点は次のとおりです。
- パックが輪になって並ぶ。
- 1つを選ぶと、その1つが寄ってくる。
- 袋を切ると光る。
- 無地の背景に、カードが大きく1枚ずつ、横に送られて出る。
- 最後に、上に3枚、下に2枚の一覧になる。
- 文字は最小限。
これを写した新しい流れは、BOX(箱)→パックの輪(8個、ドラッグで回る)→切る(3Dの袋と火花と閃光)→1枚ずつ(◆の数と名前、背景色がレア度で変わる)→一覧(上3下2、押すと見直せる)→もう1パック、です。
罠1:display:none のキャンバスは大きさ0になる
画面(BOX、輪、開封、1枚ずつ、一覧)の切り替えで、display:none で隠したカードの WebGL キャンバスは、大きさが0になりました。display:none の要素はレイアウトに参加しないため、clientWidth や offsetWidth が0になり、キャンバスの大きさを要素の大きさから決めている実装では、0×0 で作られてしまうからです。
直し方は、隠したい画面を重ねて置き、visibility で隠す方式にしました。
.show2 .screen { position: fixed; inset: 0; display: flex;
visibility: hidden; pointer-events: none; }
.show2 .screen.act { visibility: visible; pointer-events: auto; }
visibility:hidden の要素はレイアウトに残るので、大きさは保たれます。カードの切り替えも同じ理由で visibility を使っています。
罠2:WebGL のコンテキストの上限は16
Chrome では、1ページで同時に持てる WebGL のコンテキストは16個です。カードごとに WebGL を作ると、すぐ近づきます。対処は2つです(2つ目は、コードに入れている片付けの処理です)。
- カードの描画は、WebGL を1つだけ作って2Dキャンバスへ写す方式にした(深度マップの記事と同じ)。
- 画面を離れるときに、three.js のレンダラーを確実に片付ける。
dispose()とforceContextLoss()を呼ぶと、コンテキストをすぐ手放せます。
dispose() {
disposed = true;
cancelAnimationFrame(frame);
observer?.disconnect();
textures.forEach(t => t.dispose());
shadowTex.dispose(); shadowMat.dispose();
environment.dispose(); pouch.dispose?.();
renderer.dispose();
renderer.forceContextLoss();
canvas.remove();
}
また、カードの枚数に関係なく5枠を使い回す設計にしました。画面に出す5枚ぶんの枠(data-dyn)に、引いたカードの中身を差し替える方式です。
罠3:袋が紙に見える
パックの袋は、アルミの照り返しが命です。ところが、箱の背景に合わせて照り返しの値を下げたところ、袋が「ペラペラの紙」に見えました。値は、照り返しの強さ(envMapIntensity)を0.3、粗さを0.45以上、クリアコートを0にしていました。9月30日に別の部品で「低すぎると光沢が消えて紙に見えた」と直した値は2.2で、それを下回って、同じ失敗をくり返した形です。
原因は2つありました。
- 照り返しの値が低く、光沢が消えた。
- 箱の中のパックは15段に平らに積んであり、低い視点からは一番上の表面が細く見えるだけで、厚みも封のギザギザも見えなかった。
直しは、次のとおりです。
for (const surface of Object.values(pouch.materials))
surface.envMapIntensity = 2.2; // 箱と同じ値に戻す。箱の紙は低いまま
item.scale.z = 1.8; // ふくらみを強める
if (!remaining.some(i => i % 2 === col && i > index)) { // 列の一番上だけ
const lift = col ? .2 : .12; // 右 .2、左 .12 ラジアン
item.rotation.x += lift;
item.position.y += 50.5 * Math.sin(lift) + 1.2;
item.rotation.z = col ? -.05 : .04; // 左右で角度を変える
}
照り返しを戻しただけでは、別の評価者の比較で「わずかに良い」止まりでした。列の一番上の奥を持ち上げると、封と光沢が見えて、袋に見えるようになりました。手前に平らに寝かせていた1パックは、同じ理由(寝かせると印刷した紙に見えた)で別の作業で消されていました。
抽選:5枚の衣装がかぶらない
最初の抽選は、5枚をランダムに引くだけでした。動画を作ると、5枚が全部同じ写真に見える、という問題が出ました。原因は、別々の名前をつけた2組の写真が、実は同じ衣装だったためです。
そこで、写真を衣装ごとの5つの組にまとめ、パックは各組から1枚ずつ引くことにしました。毎回5種類の衣装が出ます。枠ごとのレア度の重みは、後ろの枠ほど高いレア度が出やすくしてあります。
const pick = o => { let r = Math.random() * Object.values(o).reduce((a, b) => a + b, 0);
for (const k in o) { r -= o[k]; if (r <= 0) return k; } return Object.keys(o)[0]; };
const gs = shuffle(GROUPS); // 5つの衣装の組
const w = [{ n: 35, l: 25, h: 40 }, { h: 45, d: 55 }, { d: 100 }, { d: 55, o: 45 }, { o: 100 }];
pack = gs.map((g, i) => {
const c = BASE.filter(x => x.g === g); // その組の中から1枚
return { b: c[(Math.random() * c.length) | 0], r: RAR[pick(w[i])] };
});
if (Math.random() < .3) { // 30% で特別なカードが入る
const sp = SPR[(Math.random() * SPR.length) | 0], i = gs.indexOf(sp.g);
pack.splice(i, 1); pack.push({ sp }); // 同じ衣装の1枚と入れ替える
}
n はふつう、l はラメ、h はホロ、d は立体、o は飛び出すです。特別なカードは、同じ衣装の組の1枚と入れ替えるので、衣装のかぶりは増えません。変わり絵は衣装がかぶるので、ランダムの対象から外しました。
そのほかの罠
- 1行の
constの途中に//の注釈を入れると、後ろが全部消える(同じ行の続きもコメントになる)。 - three.js は、片付けた直後に描くと
trimのエラーになる。原因の細かい調べは記録に残っていませんが、片付けた後に描画を呼ばない順序にして避けています。 - 箱ごと
box-shadowを付けると、「白い枠に黒い中身」に見える。 - 時計を止めて動画に録るときは、
requestAnimationFrameの時刻とperformance.now()をそろえる。 - 出てくるカードの角が尖って見えたので、角丸の形に作り直した(
rounded(63, 88, 3.3))。
測った結果と、残った弱点
検証は、スマホ幅とPC幅の実ブラウザで、BOXから最後まで通ること、エラー0、衣装の重複なしでした。別の評価者の採点は、動画で6点、衣装を変えて作り直した版で6.5点(10点満点)でした。残っている指摘は、切り抜きの縁、BOXが小さいこと、火花が弱いことです。
まとめ
動画を0.2秒ごとのコマにして観察すると、演出の順番が写し取れます。three.js では、display:none を避けて visibility を使うこと、WebGL のコンテキストを16個以内に抑えて確実に片付けること、袋の照り返しは低くしすぎないこと、が要点でした。抽選は、衣装の組ごとに1枚ずつ引くと、かぶりません。レア表現の作り方もあわせて読んでください。