ARの吹き出しが「板に貼った字幕」に見えないためにやった5つのこと

ARKitの映像にすりガラスが効かない制約を3本の反射光で越え、1€フィルタによる手ぶれ除去、バネアニメの撤去、見かけの株サイズに比例する拡縮で空間に吹き出しを定着させた記録。

#ARKit#SwiftUI#アルゴリズム

植物と話すARアプリ「Plavo(プラボ)」では、カメラに映った観葉植物からセリフの吹き出しが浮かび上がります。この吹き出しはこのプロダクトの主役ですが、最初のプロトタイプを実機で動かしたとき、その見た目はとても「植物が話している」ようには見えませんでした。

真っ白なベタ塗りの吹き出しが植物の真上にペタッと張り付き、手元のスマホがわずかに揺れるたびに吹き出しのしっぽが小刻みに震え、カメラを少し横に振ると吹き出しだけが画面の枠に引っかかって遅れてついてくる——まるで**「カメラ映像の上に貼られた透明なアクリル板の字幕」**にしか見えなかったのです。

AR空間の中に実在する吹き出しとして感じてもらうには、見た目の描画だけでなく、3D空間へのアンカーの打ち方、しっぽの幾何学、手ぶれフィルタ、そして奥行き推定の補正まで、いくつもの層で根本から設計をやり直す必要がありました。その過程で決めた5つのことをまとめます。

1. Material のぼかしを捨て、3本までの反射ラインで「風船」を描く

最初は、白いベタ塗りで後ろの植物を隠してしまうのを避けるため、iOS標準のすりガラス素材(SwiftUIの Material や UIVisualEffectView)で半透明の吹き出しを作ろうとしました。しかし、実際にカメラ画面に重ねてみると、すりガラスのぼかしがまったく効かず、ただの単色の板になってしまいました。

理由は、ARKitがMetalで直接描画しているカメラ映像を、UIVisualEffectView が背景としてキャプチャできないためです。背景の植物がどれだけ動いても、その上のすりガラス素材には何も映り込みません。

そこで、背景をぼかすアプローチをきっぱり諦め、**「白の重なりと艶で描く、半透明でポップな風船」**へと方向を切り替えました。後ろの植物がうっすら透けることで、主役である実物の植物を隠さずに済みます。

重ねる層役割と描き方
ベース(地)左上が濃く、右下が薄い白の線形グラデーション。不透明度は周囲の明るさで変動
内側の照り輪郭をぼかして内側に落とした光。平面ではなく「丸みのある立体」であることを伝える
反射(ハイライト)輪郭の角の丸みに沿って走る光の筋。左上に長短1本ずつ、右下に1本の計3本まで
縁と影上ほど明るい白グラデーションの細い縁取りと、広い影・狭い影の2枚重ね

ここで一番効いたのは、ハイライトの入れ方です。最初は風船の中央あたりにぼかした白い楕円を置いていたのですが、それだと「平らな板に白い汚れが乗っている」ようにしか見えませんでした。丸い球面や風船に映り込む光は、必ず輪郭の縁(リム)に沿って走るからです。角の丸みに沿って光の筋を走らせ、短い1本を少しだけ離して置いた瞬間、一目で「膨らんだ風船」に見えるようになりました。ただし、反射の筋を4本以上に増やすと急に写実的になりすぎて絵としての軽さが消えるため、「3本まで」を上限にしています。

また、ベースの白の不透明度は固定にせず、ARKitが毎フレーム計測する周囲の明るさ(ambientBrightness)を使って 0.66〜0.86 の範囲で動的に変化させています。明るい窓際ほど白を濃くしないと、透けた明るい背景に黒い文字が負けて読めなくなるためです。

2. 空間に2つの3D点を打ち、象限の切り替えを徹底的に渋る

吹き出しが「板に貼った字幕」に見えていた最大の原因は、置き場所の計算順序にありました。当初は「植物の真上」に吹き出しを置き、画面の端からはみ出しそうになったら画面内に収まるように位置を補正していました。

これをやると、スマホを少し横に振って植物が画面の端へ寄るたびに、吹き出しが画面の枠に押されて植物に対する相対位置を変え、端末を振るたびに吹き出しが画面の中央へ逃げてくるという奇妙な動きになります。それは空間に固定されているとは言えません。カメラを植物から外せば、吹き出しも一緒に画面の外へ消えていくのが自然です。

そこで、3D空間の中に2つの点を固定して毎フレーム投影する方式に変えました。

  • 株の点: 葉の塊の中心。しっぽが指すターゲット
  • 本体の点: 株の点から左上・右上・左下・右下のいずれか斜めにずらした3D位置

真上には置きません。真上に置くと植物の頭を隠してしまいますし、植物が画面の上寄りに映っているときにすぐ画面外へ飛び出してしまうからです。

重要なのは、**「画面の枠を見てよいのは、最初に4つの斜め象限(左上・右上・左下・右下)からどれを選ぶか決める瞬間の1回きり」**というルールです。一度「右上」と決めて3D空間に本体の点を打ったら、あとは画面の端から出そうになっても絶対に画面都合で押し戻しません。

ただし、ユーザーが植物の周りを歩いて回り込んだり、後述する奥行き補正で株の点が少しずつ動いたりすると、斜めに置いたはずの吹き出しが株に被ったり(株の枠の35%超)、あるべき相対位置から本体の大きさの0.5倍以上離れたりします。そのときだけ別の象限へ置き直しますが、ここでも**乗り換えを徹底的に渋る(ヒステリシスを入れる)**ようにしました。ずれた状態が 1.5秒以上連続し、かつ前回の移動から 4秒以上経過しているときだけ、時定数0.18秒でなめらかに新しい象限へ移動させます。これで、少しカメラを動かしたくらいで吹き出しが落ち着きなく飛び回る現象が消えました。

3. しっぽは「太く短く」、先端の円に38度で接しさせて Path.union する

次に悩まされたのが、吹き出しから植物へ向かって伸びる「しっぽ」の形です。最初は細く尖った三角形のしっぽを株の中心へ正確に向けようとしていましたが、細長いしっぽは「矢印」として機能してしまうため、手ぶれや認識のわずかなズレがそのまま「指している先が揺れている」という違和感として目につきました。

そこで方針を転換し、正確さよりも形の安定を優先しました。付け根の幅を 34pt、長さを 21pt と、長さよりも幅を広く取った「太く短いしっぽ」にし、先端を株の点に無理やり重ねに行くのではなく、向きだけを辺の垂直から 15〜45度の範囲で株の方向へ寄せるだけにとどめました。

さらに、丸い風船から自然に生えているしっぽに見せるため、形状を次の3つの幾何ルールで組み立てています。

決めごと数値崩すとどう見えてしまうか
付け根は本体の辺と同じ水平方向に出る水平を保つ長さ = 付け根幅の 0.30付け根にカクッとした角が立ち、後から板を貼り足したように見える
側面は緩やかな3次ベジェ曲線1本で結ぶ両端の接線方向のみ固定直線だと、柔らかい風船に硬い三角定規が刺さったように見える
先端の小さな円(半径2.5pt)に斜めから接する真横から 38度 の角度で接する真横(0度)に近い角度で受けると、先端付近が同じ太さの茎になり、蛇口から水が垂れたように見える

しっぽを斜めに傾けるときも、しっぽの図形全体を回転させてはいけません。図形ごと回すと付け根の片側が本体の辺から浮き上がり、せっかくの丸い繋がりが壊れてしまいます。付け根は辺の上に水平に残したまま、先端の円の座標だけをずらし、寝かせるほど寸詰まりに見えないよう長さを最大22%伸ばし、付け根を最大18%狭める補正を入れています。

そして仕上げに、本体の角丸矩形としっぽのパスを Path.union で1つの図形に合成してから描画します。2つの図形を別々に描いて重ねると、本体が半透明であるがゆえに、しっぽの付け根を横切る本体の縁のハイライト線が透けて見え、くっきりとした「継ぎ目の線」が出てしまうからです。

4. 1€フィルタと不感帯で震えを消し、.animation(.spring) を外す

空間の点を毎フレーム画面座標へ投影すると、手ぶれとカメラの姿勢推定の微細なノイズで座標が常に小刻みに震えます。最初は「前のフレームの座標に0.25ずつ寄せる」という単純な指数移動平均(EMA)を入れていましたが、一定の係数では**「止まっているときの手ぶれを消そうとして係数を小さくすると、カメラを動かしたときに吹き出しが遅れてついてくる」**というジレンマから抜け出せませんでした。

そこで導入したのが、VRやARのポインタ平滑化で定番の 1€フィルタ(One Euro Filter) です。動きの速さに応じて、毎フレーム平滑化のカットオフ周波数を動的に変えます。

  • ほとんど動いていないとき(minCutoff: 0.8 Hz): 強く均して、手ぶれの細かい震えをピタッと消す
  • カメラを速く振ったとき(beta: 0.010): フィルタを弱め、向けた先へ遅れずに即座に追従させる
  • 不感帯(Deadband: 1.2pt): フィルタを通しても残る1pt未満の揺れは切り捨て、SwiftUIの @Observable が毎フレーム再評価されるのも防ぐ

これと合わせて、コードから削除して劇的に良くなったのが、投影座標に対して掛けていた .animation(.spring, value: bubbleScreenPoint) です。60fpsで毎フレーム目標値が書き換わる座標にSwiftUIのバネアニメーションを掛けると、バネが目標に追いつく前に次の目標が来るため、吹き出しが水中を泳ぐように遅れ、カメラを止めた瞬間には行き過ぎて跳ね返る(オーバーシュートする)原因になっていました。アニメーションを外し、1€フィルタ一本に任せたことで、空間にピタッと張り付く手応えが出ました。

また、吹き出しの大きさも「カメラからの距離(メートル)」ではなく、**「画面上の株の見かけの大きさ(幅と高さの平均)の 0.25倍」**を1行分の高さにする方式(D50-a)に変えました。距離だけで拡縮していた頃は、小さな鉢植えを見るためにカメラを近づけると、小さな植物に対して巨大な吹き出しが出てしまっていたからです。株の見かけの大きさに比例させれば、小さな株には小さな吹き出しが付き、遠ざかれば株と一緒に自然に小さくなります。

5. レイキャストが外れたときの「画面中央落ち」と視差ずれを直す

どれだけフィルタを磨いても、そもそも3D空間に打ったアンカーの座標が間違っていれば、吹き出しは植物から離れてしまいます。実機で調べてみると、アンカーがずれる致命的なバグが2つ隠れていました。

1つ目は、奥行き(レイキャスト)が取れなかったときのフォールバック先です。葉っぱや細い茎の集まりである観葉植物は、ARKitの平面検出が最も苦手とする被写体で、レイキャストが空振りすることが珍しくありません。そのとき「距離1.2mに置く」というフォールバックを入れていたのですが、その方向が**常にカメラの正面(画面の中央)**になっていました。つまり、植物が画面の右端に映っているときにレイキャストが外れると、画面のど真ん中にアンカーが打たれていたのです。これを、植物の2D検出枠の中心を通る視線ベクトル(ARView.ray(through:))の1.2m先に置くように修正しました。

2つ目は、一度打ったアンカーを二度と直していなかったことです。最初の1回でレイキャストが外れて「本当は0.5m先にある植物なのに、視線上の1.2m先にアンカーを打ってしまった」とします。その瞬間は同じ視線上にあるので植物の上に重なって見えますが、そこからスマホをほんの少し横に動かしただけで、0.5mと1.2mの**視差(パララックス)**によって、吹き出しが植物から大きく剥がれて泳ぎ出します。手ぶれで吹き出しが揺れて見えていた最大の犯人は、実は姿勢推定のノイズではなくこの奥行き誤差による視差でした。

そこで、話し相手が決まったあとも1.2秒間隔でバックグラウンド検出を走らせ、新しい3D位置が取れたら既存のアンカー位置を **0.25 の割合で少しずつ寄せ直す(refineAnchor)**ようにしました。1回の検出結果に100%飛びつくと、隣の鉢や通りがかりの人を誤検出した瞬間に吹き出しが持っていかれるため、0.5m以上離れた異常値は捨てつつ、じわじわと本当の奥行きへ収束させています。

ガラスの表現や3Dの物理演算と同じで、「なぜか安っぽく見える」「なぜか字幕に見える」という違和感の裏には、必ず幾何学や座標系、フィルタリングの明確な理由があります。5つの原因を1つずつ剥がしていった結果、ようやく植物のすぐ隣にぷかりと浮かぶ、軽やかな風船になってくれました。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る