HTML断片をバラまいた midori248 の再構成実験記
最初の一回で全部が吹き飛んだ。一度つまずいた。
HtmlAnimation(HTMLアニメーション用のクラス)を真っ二つにして空間に散らせば、HUD がもっと立体的に見えるはずだと期待した。でも 256 断片の補間は想像以上に重かった。
transitionDuration(遷移時間)をいじり、emissiveIntensity(発光強度)の波を測りながら落ち着けた記録です。
作ったもの
splitCount=8 で 4 枚の HtmlAnimation を 8×8 に割り、計 256 枚の PlaneGeometry を散開→集合させる HUD。holdDuration=180、transitionDuration=300、トータル 780 フレームの三角波で position・rotation・scale・emissive を補間します。散開中は spiralRadius=400〜600、verticalAmplitude=200〜300 を乱数で振って空間に浮遊させています。
これは課題。
散開に 300 フレーム、滞空 180 フレーム、帰還 300 フレーム。transitionProgress が 0→1→0 と往復するだけなのに、sin/cos を 256 回叩くと GPU が唸る。
前提として押さえたこと
- Two-phase triangle wave:
cycleCounter(サイクルカウンター)を 780 フレームでリセットし、hold→go→return の三角波を作る。
- segmentGeometry.attributes.uv.setXY: 各断片で UV(テクスチャ座標)を
(i / split, j / split) へ再割当して CanvasTexture(Canvasの内容を3D空間に貼り付けるためのテクスチャ)を共有。
- material.clone(): 断片ごとに clone するので emissive/color は独立。ただし map 参照は共有。
- extrudeFactor:
mesh.scale.z = targetScale * transitionProgress(遷移の進行度に応じてスケールを変更)で平面に厚みを付けようとしているが PlaneGeometry(平面の形状データ)なので視覚的な厚みは限定的。
- interactiveConfig:
.clickable-area がテンプレートに無いまま 256 断片を作るため、クリック判定は全て default に落ちる。
三角波と modePhase の相関を分解したい
holdDuration=180(保持時間180フレーム)と transitionDuration=300(遷移時間300フレーム)の組み合わせで transitionProgress(遷移の進行度)を生成しています。780 フレームで往復する三角波なので、globalPhase=0.015(グローバル位相0.015)のままでも 52 秒弱でループ。sin/cos を掛けると散開が滑らかに見えるけれど CPU コストは馬鹿にならない。
transitionProgress が 0 のまま 180 フレーム維持されるので、散開に入る瞬間にメッシュが溜まって見える。焦った。でも対処できた。値の跳ね方を三角波で描くと気持ちが落ち着いた。
断片ごとの UV 割当を確認する
PlaneGeometry(平面の形状データ)を 8×8 に割った後で uv.setXY()(UV座標を設定する関数)を書き換え、1 枚の CanvasTexture を使い回しています。i / split と (i+1)/split の差分が 0.125。64 セグメントすべてでこの計算を繰り返す。
splitCount を変えると uv.setXY の値が即座に変わる。最初は 8×8 で十分だと思ったけれど、拡大すると境界がカクつく。細かくしすぎるとパフォーマンスが落ちる。悩ましい。
スケールと emissiveIntensity の補間
targetScale = baseScale * (1 - t) + scatteredScale * t、scatteredScale = 0.8 + sin(time*2)*0.2。さらに emissiveIntensity は 0.5 + sin(time*3 + uniqueOffset) * 0.5 * t。transitionProgress の値次第でパネルの厚みが決まる。
transitionProgress が 0.6 を超えると scale が 0.88 まで下がり、emissive が 0.95 まで跳ね上がる。光りすぎる。眩しい。
material.clone() が 256 回走る負荷
segmentMesh = new THREE.Mesh(segmentGeometry, material.clone())(メッシュを作成し、マテリアルを複製)なので、HUD1枚あたり 64 clone、4 枚で 256 clone。CanvasTexture を共有しているとはいえ、emissive(発光色)を個別に持つため GC(ガベージコレクション)が 256 個の material を追いかける。
CanvasTexture 1024px でも総転送量は 1.0MB 程度。でも clone 数が 256 を超えると emissive の更新で GC が頻発する。負荷は想定以上。想定が甘かった。
断片が増えてもクリック判定は空振り
テンプレートには .clickable-area が無い。256 回 querySelector('.clickable-area')(DOM要素を検索する関数)を実行しても結果は全部 null。Raycaster(マウス位置から3D空間のオブジェクトを検出する仕組み)で得た UV(テクスチャ座標)は 1920×1080 に戻せても、ヒットボックスが存在しない。
default の (10,10,100,50) が 256 回返る。ここまで断片を用意しても HUD は触れない。テンプレートを直さない限り、クリックは空振りのまま。
scene.add を二度呼んでいた
ループ内で scene.add(segmentMesh) し、最後に meshes.forEach(({ mesh }) => scene.add(mesh)) をもう一度呼んでいました。Three.js は同じメッシュを add し直すと親を付け替えるだけなので動作は変わらない。でも冗長。可読性が落ちる。
結果は 1 個の子ノードのまま。事故にはならないけれど、レビューで突っ込まれる未来が見えた。
フレーム予算はギリギリ
位置を線形補間、回転補間、Math.sin Math.cos、発光色計算を 256 回ループすると、1 フレームあたりの演算数が軽く数千を超える。ns で換算すると 3〜4ms が飛ぶ。16.6ms(1フレームの処理時間)の予算が溶ける。
比率が 25% を超えると 60fps が保てない。transitionProgress の周期が長いだけに、散開時の負荷が目立つ。失敗を認めて最適化を探した。
コード抜粋: 断片生成と散開処理
const splitCount = 8;
const meshes = [];
for (let i = 0; i < splitCount; i++) {
for (let j = 0; j < splitCount; j++) {
const segmentGeometry = new THREE.PlaneGeometry(width / splitCount, height / splitCount);
const uvs = segmentGeometry.attributes.uv;
uvs.setXY(0, i / splitCount, j / splitCount);
uvs.setXY(1, (i + 1) / splitCount, j / splitCount);
uvs.setXY(2, (i + 1) / splitCount, (j + 1) / splitCount);
uvs.setXY(3, i / splitCount, (j + 1) / splitCount);
const segmentMesh = new THREE.Mesh(segmentGeometry, material.clone());
segmentMesh.userData = {
initialPos: basePosition.clone(),
spiralRadius: 400 + Math.random() * 200,
verticalAmplitude: 200 + Math.random() * 100,
timeOffset: Math.random() * Math.PI * 2,
uniqueOffset: Math.random() * Math.PI * 2
};
meshes.push({ mesh: segmentMesh });
scene.add(segmentMesh);
}
}
addAnimationCallback(() => {
const cycleFrame = (cycleCounter + 1) % totalCycle;
const t = computeTransitionProgress(cycleFrame);
meshes.forEach(({ mesh }) => {
const time = globalPhase + mesh.userData.timeOffset;
const scatter = computeScatteredPosition(mesh.userData, time);
mesh.position.lerp(scatter, 0.05);
mesh.scale.x = lerp(1, scatteredScale(time), t);
mesh.scale.z = mesh.scale.x * t; // extrudeFactor
});
});
splitCount と computeTransitionProgress の数字がそのまま負荷に直結する。sin/cos の呼び出しを減らすか、断片数を落とすか、どちらかしかない。
散開サイクルを全画面で追いかける
不安だった。
でも三角波を描いて数字を眺めていたら、理屈が体に落ちてきた。試す価値はある。
使ってみて
断片化した HUD は旧サイトで見られます。
旧サイトの midori248 を開く
- splitCount を 8 にすると 1 HUD あたり 64 断片。4 HUD で 256 clone、emissive の更新コストが跳ね上がる。
.clickable-area が無いので、Raycaster のヒットボックスはすべて default (10,10,100,50)。クリックは飾りです。
- OffscreenCanvas 非対応環境では HtmlAnimation4 のフォールバック条件が逆で落ちる。
typeof !== 'undefined' に修正が必須。
transitionProgress を操作しながら散開周期を理解する
まとめ
今回は、HTML断片をバラまいた再構成実験の全体の流れを説明しました。
ポイントは以下の5つ:
- 256断片の生成:splitCount=8 / HUD4枚で256断片。複数の非同期処理を同時に実行する仕組みで起動するが、散開中の演算コストがフレーム予算の25%を食う
- 三角波による遷移:遷移の進行度は780フレーム周期の三角波。保持時間180フレームの間は完全に静止する
- UV属性の再割当:セグメントのUV属性を再割当してCanvasTextureを共有する仕組みは成功。ただしマテリアルの複製が256個に増えた
- クリック判定の課題:
.clickable-areaがテンプレートに存在しないため、マウス位置から3D空間のオブジェクトを検出する仕組みのクリック判定はすべてdefault値になる
- OffscreenCanvas判定の修正:条件が逆で、非対応ブラウザでは例外が発生する。フォールバックの修正が必須
まずは全体の流れを理解することが重要です。断片を散らすのは派手だけど、数字を眺め続ける作業は地道です。また一緒に遊んでください。
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。