Vol.6
カメラ映像に Three.js を重ねる — 直交カメラ、root.scale = 17、Bloom の解像度
座標の流れ
MediaPipe のランドマークは「映像の」0〜1 の正規化座標です。一方、画面のキャンバスは映像を object-fit の cover / contain で描いているので、映像の座標とキャンバスの座標は一致しません。cover で上下が切れていれば、その分だけずれます。そこでまず mapMediaPipePointToFitCanvas() でキャンバス上の位置に直し、それをキャンバスに対する 0〜1 に戻してから 3D レイヤーへ渡します。
3D レイヤー側は、全レイヤー共通の直交カメラ(高さ 4.8)を使い、幅はキャンバスのアスペクト比から決めます。透視投影にしないのは、手との位置合わせが 2D で完結している以上、奥行きで大きさが変わると「手に吸い付いている」見た目が崩れるからです。
1const VIEW_HEIGHT = 4.8; // 全 3D レイヤー共通2const RASENGAN_ROOT_SCALE = 17; // 見かけの大きさはここだけで決める3const ANCHOR_DAMP_LAMBDA = 18; // 手ブレの指数減衰4 5const viewW = VIEW_HEIGHT * (canvasW / canvasH);6targetPos.set((anchor.x - 0.5) * viewW, (0.5 - anchor.y) * VIEW_HEIGHT, 0);7vfx.root.position.x = THREE.MathUtils.damp(vfx.root.position.x, targetPos.x, ANCHOR_DAMP_LAMBDA, deltaSec);8vfx.root.position.y = THREE.MathUtils.damp(vfx.root.position.y, targetPos.y, ANCHOR_DAMP_LAMBDA, deltaSec);damp は「経過時間に対する指数減衰」なので、フレームレートが 30fps でも 120fps でも同じ速さで追従します。Call Studio の描画は 120fps を超えることがある(useCallStudioHandSignTrigger のコメント)ので、フレーム数ベースの lerp では端末ごとに追従速度が変わってしまいます。
大きさは root.scale だけで変える
螺旋丸の VFX 本体は /dev/rasengan の単体ページと同じパラメータで作り、AR レイヤーでは root.scale = 17 を掛けるだけにしています。本体のジオメトリや粒子の初期配置を AR 用に書き換えないのは、単体ページで調整した見た目(Bloom のかかり方・粒子密度)が AR でも同じになるようにするためです。Call Studio で「1.3 倍にしたい」となったとき(2026-09-23)も、変えたのはスケールだけでした。
ただし Bloom はスクリーン空間の処理なので、スケールを変えると画面上の発光の広がり方も変わります。「本体は不変」でも見た目が完全に同じになるわけではない点は注意が必要です。
Bloom の解像度: 術ごとに方針が違う
- メリット
- フルスクリーンパスを高解像度化する方向の重量化をしない。コード内コメントの言葉では「通話の低遅延を優先」。
- デメリット
- 下げる方向の節約もしない。低品質でも Bloom のフィルレートは変わらず、強さ・閾値・半径しか調整できない。
- メリット
- low 0.5 / standard 0.65 / high 0.85 / ultra 1.0。低品質ほど Bloom のフィルレートが下がる。
- デメリット
- 低解像度の Bloom は発光がぼやける。
採用理由: 千鳥は雷の本数が多く、コミットにある「cut bloom cost on soft GPUs」のとおり、性能の低い GPU で Bloom の負荷が問題になったため解像度を落とす方を選んだ。螺旋丸は先に作られた術で、Bloom の解像度を上げない方針だけが決まっている。
ハマった点: テーブルに書いた値が効いていなかった
2026-09-22、千鳥の Bloom を軽くするために BLOOM_BY_QUALITY に pixelRatio を追加しました。ところが EffectComposer に setPixelRatio を呼んでいなかったため、low / standard でも Bloom はフル解像度のまま走っていました。直後のコミットで「the table already carried pixelRatio; wire it into EffectComposer so low/standard paths actually run cheaper bloom」として配線し直しています。
1const BLOOM_BY_QUALITY = {2 low: { strength: 0.42, radius: 0.26, threshold: 0.58, pixelRatio: 0.5 },3 standard: { strength: 0.55, radius: 0.3, threshold: 0.55, pixelRatio: 0.65 },4 high: { strength: 0.72, radius: 0.32, threshold: 0.55, pixelRatio: 0.85 },5 ultra: { strength: 0.85, radius: 0.38, threshold: 0.48, pixelRatio: 1 }6};7 8const bloomConfig = BLOOM_BY_QUALITY[quality];9const composer = new EffectComposer(renderer, composerTarget);10composer.setPixelRatio(bloomConfig.pixelRatio); // ← これが抜けていた同じ日に、千鳥の単体テストページでは renderer の pixelRatio 上限を devicePixelRatio の 2 から 1.5 に下げ、コンポーザを 0.5 で走らせる変更も入れています。
実装から得た知見
- 手のランドマークは「映像の」座標。cover / contain で描いたキャンバスに重ねるなら、先にフィットの変換を通す。
- 追従は時間ベースの damp で書く。描画が 120fps を超える環境があるので、フレーム数ベースの補間は端末で速さが変わる。
- 品質テーブルに値を足したら、それが実際にレンダラへ渡っているかを確認する。テーブルは正しくても配線が抜けていると何も変わらない。
- Bloom のような全画面パスは、強さや閾値を下げてもコストはほとんど変わらない。重さを下げたいなら解像度(pixelRatio)を段階化する必要がある。