地図/幾何/SDFと双曲

レイマーチング — 距離ぶん一気に進む

SDF は空間の各点から一番近い面までの距離を返す。その距離は、面を突き抜けずに安全に進める歩幅になる。光線を一本飛ばして、いまいる点で SDF を測り、その距離ぶんだけ前へジャンプする。形に近づくほど歩幅が縮み、ぶつかる直前で止まる。

SDF の値ぶんだけ光線を進める手法が球面トレース(sphere tracing)。いる点を中心に半径=SDF の値の球(2D では円)を描くと、その中には必ず形が無い。だからその半径ぶん進んでも形を飛び越さない。固定歩幅と違い、遠い所では大きく跳び、面の近くでは自動的に細かく刻むので、無駄な反復と突き抜けの両方を避けられる。SDF を全空間の距離関数として持つこの手法は、Inigo Quilez が Shadertoy で広めた。

光線を一本だけ歩かせて、進んだ点に丸を打つ。左の光源から、中央の円のふちすれすれを掠める向きへ飛ばす。SDF が返す距離ぶんジャンプするので、円から遠い所では大きく飛んで丸の間隔が広く、ふちに近づくほど歩幅が縮んで丸が密に詰まる。最接近を過ぎると、また間隔が開いて円の脇を通り抜けていく。

歩幅をきめているのは sd = 中心までの距離 − 半径、円の SDF そのもの。そのぶんだけ進めば形を突き抜けない保証があるので一気にジャンプできる。sd < 1 でほぼ接触とみなして止める。固定の歩幅で進めると、遠くで無駄に刻むか、近くで形を飛び越すかのどちらかになる。SDF を歩幅にすると、その両方が同時に避けられる。丸が最接近で団子になるのは、そこで sd が小さくなって一歩が縮むため。

// 球面トレースの一歩。sd ぶん進めば形を突き抜けない
const sd = Math.hypot(x - cx, y - cy) - radius // 円の SDF
if (sd < 1) break        // ほぼ接触、ここで止める
x += dx * sd
y += dy * sd

円ひとつだと SDF が一行で済む。形を二つにして smin で繋ぐと、scene がそのまま光線の通る場になる。march は当たりまでのステップ数と旅した距離を持ち帰る。

const scene = (x, y) => {
  const dA = Math.hypot(x - ax, y - ay) - rA
  const dB = Math.hypot(x - bx, y - by) - rB
  return smin(dA, dB, s * 0.1) // 二つの円を融合した場
}

const march = (px, py) => {
  // 光源 (lx,ly) から (px,py) の向きへ、sd ぶんずつ進む
  for (let i = 0; i < 28; i++) {
    const sd = scene(x, y)
    if (sd < 1.4) break // 当たり
    const adv = Math.min(sd, s * 0.16)
    x += dx * adv
    y += dy * adv
    dist += adv
    steps++
    if (dist > target) break // 標的の向こう、何もない
  }
  return { dist, steps }
}

光源を画面の真ん中に置いて、各セルへ向かう光線をぜんぶ march に通す。当たるまでに何ステップ要ったか、どれだけの距離を旅したかで陰影をつける。形の縁では歩幅が縮んで何度も刻むのでステップ数が嵩み、そこが暗く沈む。

陰影は steps / 16 + dist / (s * 1.6)、ステップ数と旅した距離の合成。dist > target で「その向こうに何もない」として光線を抜く。融合した二つの円が動くと、縁の沈んだ帯がそのまま伸び縮みする。この光線は二次元の平面上を這っているだけで、まだ立体ではない。

当たった点で SDF を四方に少しずらして差を取ると、距離がいちばん急に増える向きが出る。その向きが面の法線で、光の向きとの内積を取れば陰影が乗る。法線を三次元のベクトルにしてカメラ基底を組むと、平面の濃淡が立体の陰影に変わる。