AMD の GPU で、画像1枚から色付きの 3D を作る Pixal3D(C++ 版の trellis.cpp)が動くかを技術検証していたときのことです。出力の GLB に、外から見える殻とは別に、その内側にもう1組の面が入っていることがありました。外からは見えないのに面の数をかなり食うので消したいところですが、雑に消すと、表面に半分埋まった目のような部品まで消えてしまいます。

結論

  • 内側の殻とは、一番大きい成分(外側の殻)の中に丸ごと入っている成分です。量は seed や画角、入力で大きく変わりました。最初の5本の試験では最大 110,110 面、上着のパーツ1本では 145,714 面、三面図を渡す方式では 468,370 面(全体の半分)ありました。
  • 成分から最大 16 点を取り、全部が内側だったときだけ消します。内側かどうかは、軸に沿った3本の光線について交差の数の偶奇を見て、多数決で決めます。
  • 消す前と後を4方向から描いて比べます。私たちの試験では、3本で画素単位まで同じでした。

試した環境

  • Pixal3D を trellis.cpp v0.8.1(HIP でビルド)で動かしたもの。Radeon RX 7900 XT(VRAM 20 GB)、Ubuntu、ROCm 7.2.4
  • 後始末は、trellis-cli を呼ぶ自作の Python スクリプトで行います(numpy と Pillow だけ)

何が残っていたか

中に入っていたのは、服の下のもう1枚の体や、頭の中の面などです。最初の5本の試験で、量が seed と画角によって大きく変わることが分かりました。画角 20° で作ったキャラの絵では、110,110 面ありました。

例を挙げます。上着のパーツを1本作ったときは、内側の殻が 7 個・145,714 面あり、消すと 152,860 面になりました。三面図を渡す複数視点の方式では、もっと多いことがあります。自作の三面図では 69 成分・468,370 面で、全体の半分が内側の殻でした(同じ入力を1枚の重みで作った出力には、ありませんでした)。

閉じた器の中の粒から三方向へ伸びた光の筋が、器の壁を突き抜けていく

見分け方

「外側の殻の中にある」だけを条件にすると、表面から半分だけ顔を出している目のような部品も、内側に見えてしまうことがあります。そこで、成分の点が全部内側のときだけ消すことにしました。分かれ道は次のとおりです。

外側以外の成分を、残すか消すかに分ける

手順を詳しく書くと、次のようになります。

  1. 同じ位置の頂点をまとめてから、つながった成分に分けます。UV の継ぎ目で分かれた頂点は、ここで戻します。
  2. 一番大きい成分を、外側の殻とみなします。
  3. ほかの成分のうち、外側の殻の外接箱からはみ出す物は、調べずに外とみなします。
  4. 残った成分から、頂点を最大 16 点取ります。辺や頂点にちょうど当たるのを避けるため、ごく小さな揺らぎ(1e-7)を足します。
  5. 点ごとに、軸に沿った3本の光線(+X・+Y・-Z)が外側の殻と何回交わるかを数えます(Möller–Trumbore)。奇数になる軸が2本以上なら、その点は内側とみなします。三角形を全部当てると遅いので、光線が当たりうる三角形を外接箱で先に絞ります。
  6. 取った点が全部内側だったときだけ、その成分を消します。

要の部分のコードです。

for li in np.argsort(-sizes)[1:]:            # 一番大きい成分(外側の殻)の次から
    mine = roots == labels[li]
    vid = np.unique(Fw[mine])
    pts = Vw[vid]
    if (pts.min(axis=0) < bmin).any() or (pts.max(axis=0) > bmax).any():
        continue                               # 外側の殻の外接箱からはみ出す成分は外とみる
    pick = pts[rng.choice(len(pts), size=min(samples, len(pts)), replace=False)]
    pick = pick + rng.normal(scale=1e-7, size=pick.shape)   # 辺や頂点にちょうど当たるのを避ける
    if all(_inside(lo, hi, v0, e1, e2, p) for p in pick):
        enclosed |= mine

全部の点が内側という条件にしているので、半分埋まった部品は残ります。消したくないときのために、消さずに残す切り替えも付けています。

交差の偶奇で内外を決められるのは、外側の殻が閉じている(境界辺が 0)ときだけです。私たちの出力では、同じ位置の頂点をまとめた後の境界辺が、試した物で全部 0 でした。

どう確かめたか

消す前と後の両方で、形の検査(境界辺・非多様体の辺・連結成分)を記録します。そのうえで4方向から描いて比べました。3本で画素単位まで同じで、見えない面だけを消せていることを確かめました。この処理を入れた後、試験の5本はすべて、外側の殻1つになりました。

三面図を渡す方式の出力でも、同じ処理を通しています。三面図から作ったキャラクターで、内側の殻 21 個・48,810 面を消し、936,926 面になりました。

上着のパーツは 145,714 面を消して 152,860 面が残った。三面図から作ったキャラクターは 48,810 面を消して 936,926 面が残った

確かめていないこと

  • 自己交差や、部品どうしのめり込み。
  • 試験の5本のうち2本の、消す前と後の描画の比べ。
  • 内側の殻が seed や画角でここまで変わる理由。

おわりに

内側の殻は、見えないぶん気づきにくいのに、面の数を大きく食います。外側の殻に丸ごと入っている成分だけを選んで消し、消す前と後の見た目が同じことを描いて確かめれば、表面の部品を巻き込まずに済みます。