第4部 プロシージャル & ジェネラティブ — 第28章

レイマーチング

第24章で 2D の距離場を、あらゆる点で最も近い表面までの距離を返すものとして きっちり定義しました。この章はそれを 3D に持ち上げます。やることは 1 つだけ —各ピクセルから光線を 1 本飛ばし、距離関数を頼りに前へ進めて、表面に当たった場所を探す。それだけで、第3部で頂点バッファ・インデックス・法線属性・深度テスト・シャドウマップを積み上げて 作ってきた 3D の絵が、フラグメントシェーダー 1 枚の中で組み上がります。

第3部との対比が、この章のいちばんおいしいところです。頂点は 1 つも送りません。三角形は画面を覆う 1 枚だけ(第6章のハーネスのまま)で、それは形とは何の関係もありません。 形の情報はすべてmap(vec3 p)という関数の中にあります。第24章のmin/max/smin/modは、3D でもそのまま使えます —変わるのは成分が 1 つ増えることだけです。だから距離場そのものの説明は繰り返しません。この章は「レイをどう進めるか」「法線をどう作るか」 「距離場をどう舐めて影と遮蔽を作るか」の 3 点に集中します。

この章のデモ 4 本。ボタンで切り替えられます。「ステップ数」は形を塗らずに、レイが何ステップ使ったかだけを色にしたもの(暗い青 = 数ステップ、明るいほど多く使ったピクセル。ポインタ左右で方位角、上下で仰角。仰角をいちばん 下まで下げると水平線あたりがMAX_STEPSを使い切って純白になります)、「繰り返しと破綻」は同じ繰り返しを左右に並べ、右だけ形をセルの中で ずらして距離を壊した比較(ポインタ左右で方位角、上下で右側のずらし量。いちばん下で 0 = 左と同じ)、「法線と刻み幅」は中心差分で作った法線を左は色そのまま・右は Lambert で見る比較 (ポインタ左右で方位角、上下で差分の刻みepsが 10⁻⁷〜10⁻¹)、「影 + AO + フォグ」が仕上げ(ポインタ左右で方位角、上下で影のやわらかさ)です。 この章だけ描画バッファの dpr 上限を 1.5 に下げています(理由は 3 節)。

この章で学ぶこと:

1. 面を並べずに距離で描く

第3部でやっていたことを、もう一度並べてみます。頂点の位置を VBO に詰め、インデックスで三角形に 組み、頂点シェーダーがクリップ空間へ写し、ラスタライザが三角形の内側のピクセルを列挙し、 フラグメントシェーダーが色を決め、深度テストが前後を決める。形は「頂点の集まり」として存在します。

この章は逆から始めます。三角形は画面を覆う 1 枚だけで、形の情報は一切持っていません。 フラグメントシェーダーは、自分の担当するピクセルについてカメラから伸びる光線 (ray) を 1 本作り、その線上を前へ進めながらmap(p)に「いちばん近い表面までどれくらいか」を尋ね続けます。値がほぼ 0 になったところが表面です。 形は関数として存在します。

第3部: 頂点を並べて塗る形 = 頂点の集まりコスト ∝ 頂点数 + 覆ったピクセル数この章: 距離を測りながら進むeyeセンサー形 = map(p) という関数コスト ∝ ピクセル数 × ステップ数
2 つの描き方。左は形が頂点として存在し、ハードウェアのラスタライザが「三角形の内側」を 列挙してくれます。右は形が関数としてしか存在せず、ピクセルごとに自分で交点を探します。 三角形は画面を覆う 1 枚だけなので、絵に見えているシルエットは三角形の縁ではありません。

得手不得手はきれいに裏返しになります。

観点第3部(ラスタライズ)この章(レイマーチング)
形を増やすコスト頂点とドローコールが増えるmapの中の式が伸びる。同じ形の複製は座標の折り畳みで無料(4 節)
解像度を上げるコスト塗るピクセルが増える。頂点処理は変わらないピクセル数に比例してmapの評価回数がまるごと増える(この章のデモは 1 ピクセルあたりおよそ 19〜35 回)
前後関係深度バッファと深度テスト(第13章)不要。レイに沿って手前から進むので、最初に当たった面が手前
面の裏表・カリングワインディング順とカリング(第13章)概念そのものが無い。面という単位が存在しない
なめらかな合成メッシュを繋ぎ直す(MarchingCubesなど)smin1 行(第24章)。kを動かしても形の「頂点数」は増えない
任意のモデルデータ得意。glTF を読めばよい(第19章)苦手。メッシュの距離場は解析式で書けないので、3D テクスチャなどに焼く必要がある
アンチエイリアスMSAA が三角形の縁をならす(第2章のantialias)シルエットは三角形の縁ではないのでMSAA では効かない。値からぼかす手当てが 別に要る

2. カメラレイを作る

必要なのは 2 つだけです。レイの始点(カメラの位置eye)と、ピクセルごとに違う向きrd。どちらも第3部で作った部品でできます。

まず始点。第15章の軌道カメラとまったく同じ球面座標です。この章は第15章の流儀を採ります— つまりphiは水平面から測った仰角(上がプラス)で、thetaは+zから測った方位角です。第14章の球の生成では極角(南極からの角度)を使っていて基準が違うので、 第14章の式を持ってくるときは極角 = π/2 + 仰角の換算が必要になります。

次に向き。カメラの基底 — 右・上・前の 3 本の直交する単位ベクトル — をcross2 回で組み立てます。第12章でlookAtを自作したときの中身そのままです。

src/lessons/28-raymarching/march.glsl(抜粋)
mat3 cameraBasis(vec3 eye, vec3 target) {
  vec3 back = normalize(eye - target);
  vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), back));
  vec3 up = cross(back, right); // 直交する単位ベクトル同士の cross なので正規化は不要
  return mat3(right, up, -back);
}

第12章ではzAxisが「カメラの後ろ向き」で、それを使ってビュー行列(カメラの逆変換)を作りました。 ここで欲しいのは逆変換ではなくカメラそのものの姿勢なので、3 本をそのままmat3の列に並べます。ただし3 本目だけは向きを裏返して-backを入れます — backは第12章のzAxisと同じ「カメラの後ろ向き」なので、レイを前へ飛ばすには符号を反転して 「前」にする必要があるからです。第10章・第11章の「列 = 基底の行き先」の読み方どおり、このmat3にカメラ座標系のベクトルを掛けると、ワールド座標のベクトルが返ります。

そして、ピクセルの位置。ここで第4部の標準座標の 1 行が効いてきます。

src/lessons/28-raymarching/march.glsl(抜粋)
// 標準座標 p(短辺が -1〜1)を「センサー上の位置」と読み替えてレイの向きを作る。
// focal = 1 / tan(fov / 2) にすると、短辺方向の画角がちょうど fov になる
vec3 cameraRay(mat3 basis, vec2 p, float focal) {
  return normalize(basis * vec3(p, focal));
}

pは第7章以来おなじみの、短辺が -1〜1 になる座標です。これをカメラの前方focalの距離に置いた平らな「センサー」の上の位置だと読み替えるだけで、レイの向きが決まります。アスペクト比の補正はこの 1 行がすでに やってくれているので、別にaspectを掛ける必要はありません。短辺の画角がfovに固定され、長辺のほうが広く見えるという振る舞いになります。

eyefocal基底の列 2(前)センサー(短辺の半分 = 1)p.y * uprdfov / 2tan = 1 / focal
標準座標を「センサー上の位置」と読み替える。センサーの短辺の半分がちょうど 1 なので、短辺方向の 画角の半分のtanは1 / focalです。だからfocal = 1 / tan(fov / 2)と置けば、指定したfovがそのまま短辺の画角になります。第12章のperspective行列が1 / tan(fovy / 2)を対角に持っていたのと同じ量です。
src/lessons/28-raymarching/01-steps.frag(抜粋)
  vec3 eye = orbitEye(TARGET, theta, phi, RADIUS);
  mat3 basis = cameraBasis(eye, TARGET);
  vec3 rd = cameraRay(basis, p, 1.0 / tan(radians(FOV) * 0.5));

FOVは 45 度にしています。第3部のカメラ標準(FOVY= 45°・NEAR= 0.1・FAR= 100)と同じ値です。ただしnear/farに相当するものは射影行列ではなく、march のループの中の「開始位置」と「最大距離」になります。 深度バッファが無いので、深度精度の話(第13章)がそもそも出てきません。

区画ごとにセンサーを分ける

この章の比較デモ(デモ 2・デモ 3)は、画面を左右 2 区画に分けて同じシーンを並置します。センサーの 話をした直後なので、ここで仕掛けを開けておきます。2D の章のように標準座標の中でオフセットする だけでは同じ 1 枚の絵の一部を切り出すことになってしまうので、3D では区画ごとに独立したセンサーが要ります。やることは、第7章の標準座標の 1 行を 「区画の幅」で使い直すだけです。

src/lessons/28-raymarching/02-repeat.frag(抜粋)
  float halfWidth = u_resolution.x * 0.5;
  bool right = gl_FragCoord.x > halfWidth;
  vec2 origin = vec2(right ? halfWidth : 0.0, 0.0);
  float side = min(halfWidth, u_resolution.y);
  vec2 p = ((gl_FragCoord.xy - origin) * 2.0 - vec2(halfWidth, u_resolution.y)) / side;

originが区画の左下、halfWidthが区画の幅で、短辺の基準sideもmin(halfWidth, u_resolution.y)と区画の中で取り直します。これで左右それぞれが「短辺 -1〜1」の独立したセンサーになり、同じcameraBasis/cameraRayをそのまま 2 回使えます。カメラは共通のまま、区画ごとに変えるのはシーンの側だけ(デモ 2 はずらし量、デモ 3 は法線の刻み)という作りです。

3. sphere tracing — 距離のぶんだけ進む

レイができました。あとは表面を探すだけです。素朴には「小さい歩幅で少しずつ進んで、値が負に なったら止まる」で動きます。実際に動きますが、恐ろしく無駄です。何もない空間でも同じ細かさで 刻むからです。

距離場を持っているなら、もっと大きく進めます。いまいる点 p から半径 d = map(p) の球の内部には、表面が 1 つもありません— これは距離場の定義そのものです。だからtをdだけ増やしても、何かを飛び越す心配はありません。表面に近いところでは歩幅が自然に小さくなり、 遠いところでは大きくなる。これがsphere tracingです。

map(p) = 0 の面eye遠い = 歩幅が大きい近い = 詰まるd
各点を中心に、半径がその点でのmapの値に等しい球を描くと、その内側には表面がありません。だからその半径ぶんは無条件に進めます。表面に近づくほど球が縮み、歩幅が自動的に詰まっていきます。名前の「sphere」はこの球のことです。
src/lessons/28-raymarching/march.glsl(抜粋)
vec2 march(vec3 ro, vec3 rd, int maxSteps, float maxDist, float eps, float epsSlope) {
  float t = 0.0;
  for (int i = 0; i < maxSteps; i++) {
    float d = map(ro + rd * t);
    // ヒットのしきい値を t に比例させる。遠いほど 1 ピクセルの足跡が広いので、
    // 近くと同じ絶対値まで詰めるのは無駄になる。epsSlope = 0.0 にすれば
    // 固定のしきい値に戻る(比例の効き方の実測は本文 3 節)
    if (d < eps * (1.0 + epsSlope * t)) {
      return vec2(t, float(i + 1));
    }
    t += d;
    if (t > maxDist) {
      return vec2(-1.0, float(i + 1));
    }
  }
  return vec2(-1.0, float(maxSteps));
}

戻り値はvec2で、x が当たった距離t(外れたら -1)、y が使ったステップ数です。以降のコードに出てくるhit.xはこの距離 — 5 節では法線の刻みを0.0006 * (1.0 + hit.x)と距離に比例させ、7 節ではフォグのexp(-hit.x * FOG_DENSITY)に入ります。hit.yはステップ数で、これをそのまま色にしたのがデモ 1 です。

打ち切りの条件が 3 つあります。ヒット(値がしきい値より小さくなった)、最大距離(遠くまで行ったので背景とみなす)、最大ステップ数(それでも決まらないので諦める)。GPU では全ピクセルが同じシェーダーを走るので、この 3 つ目が実質的な予算になります。

過小評価は安全、過大評価は貫通する

第24章 2 節の落とし穴で、「この過大評価が問題になるのは第28章」と書きました。ここで回収します。

dだけ進んでよい根拠は「半径dの球の中には表面が無い」ことでした。mapが真の距離より小さい値を返しているなら、その球はもっと小さいので、 やはり中には何もありません — 過小評価はいつでも安全で、歩数が増えるだけです。 第24章で測ったとおりmin/max/sminはどれも過小評価の側にしか外れないので、合成をいくら重ねても絵は壊れません。

逆に真の距離より大きい値を返すと、球の中に表面が入ってしまいます。歩幅がそれを 飛び越え、レイは面を貫通します。どれくらい致命的かを、歩幅にわざと係数を 掛けて測りました。デモ 4 のシーンを 480×270 のレイで走査し、同じしきい値のままt += dで 400 ステップまで許した結果を基準にしています。「当たる位置が変わった」の判定は 2 つ— 基準に対して(a) ヒットと外れが入れ替わった、または (b) 当たった距離のずれがその距離での 1 ピクセルの足跡(2 * t * tan(FOV / 2) / 270)を超えた、のどちらかに当てはまるピクセルを数えています。

歩幅当たる位置が変わったピクセル(480×270)平均ステップ数(480×270)
t += d0.03%20.08
t += d * 1.020.04%19.59
t += d * 1.051.61%18.86
t += d * 1.108.66%17.65
t += d * 1.3022.87%15.43

先頭の行が 0.00% でないのは、基準が 400 ステップまで許しているのに対し、こちらはMAX_STEPS= 96 で打ち切っているためです(この 0.03% はすべてヒットと外れの入れ替わりで、貫通ではありません)。 2% ほどの欲張りは 0.04% でまだ無害ですが、5% で 1.6%・10% で 8.7%のピクセルが ずれ、30% では4 分の 1 近くが違う場所を指します。ステップ数の節約はごくわずかで、代償は絵の破綻です。歩幅をdより大きく取ってよいのは、距離場が過小評価だと分かっている場合に限られます。

ステップ数は場所によって桁違いに違う

デモ 1 は、色をステップ数そのものにしたものです。形は塗っていません。 暗い青が数ステップで、明るいほど多く使ったピクセルです(MAX_STEPSを使い切ると純白)。デモと同じ設定(ポインタ中央・u_time= 0)を短辺 360 px・16:9 で走査した実測値を挙げます。

縁で跳ね上がるのは、面をかすめるレイが「表面のすぐ横」を延々と進むからです。距離はずっと小さい ままなので、歩幅も小さいまま。同じ理由で、床を浅い角度でなめる方向も高くつきます。 デモを切り替えて、ポインタを canvas の下端へ(仰角を最低に) 持っていってください。水平線あたりがMAX_STEPSを使い切って白く飛びます。既定の視点(ポインタ未操作)では最大が 98 ステップ =MAX_STEPS128 の 0.77 倍にとどまるので、いちばん高いところでも黄色までです。仰角を最低にすると、白へ 寄り始める領域(色の式のしきい値 0.72 を超えたピクセル)が画面の 2.0%、そのうち本当に使い切って 純白になるのが 0.3% 出ます。

ヒットのしきい値を距離に比例させる

marchのヒット判定はd < eps * (1.0 + epsSlope * t)です。固定値ではなくtに比例して緩めています。理由は遠いピクセルの足跡が広いからです。画角 45 度・ 短辺 270 px なら、距離tでの 1 ピクセルの幅はワールド空間で2 * t * tan(22.5°) / 270— tが 20 なら 0.061 で、手前の 20 倍あります。近くと同じ絶対値まで詰めても、画面上では同じ 1 ピクセルに落ちるだけです。

どのくらいの傾きが妥当かを測りました。基準は「しきい値を固定して 400 ステップまで許した結果」で、 デモ 4 のシーンを 240×135 本のレイで走査しています。右の 2 列の判定はこうです。「96 ステップを使い切った」は、ループを 96 回まわしても当たらず、MAX_DISTにも届かなかったレイ — つまり予算切れで背景として抜けたものだけを数えています (MAX_DISTを超えて正当に背景になったレイは含みません)。「陰影が 0.05 以上変わった」は、 仕上げまで通した出力の sRGB 値を基準と比べて、3 成分のどれかが 0.05 以上動いたピクセルです。

しきい値平均ステップ数(240×135)96 ステップを使い切ったピクセル(240×135)基準と陰影が 0.05 以上変わったピクセル(240×135)
eps(固定)22.161.47%0.51%(すべて「当たるはずが外れた」)
eps * (1 + 0.1 t)21.050.17%0.25%
eps * (1 + 0.25 t)(採用)20.120.06%0.43%
eps * (1 + 0.5 t)19.190.00%0.82%
eps * (1 + t)17.990.00%1.88%

傾きを上げると、平均ステップ数とステップ数を使い切って背景が抜けるピクセルはどちらも単調に減ります(固定だと 96 ステップでは足りないレイが 1.47% 出てしまう)。代わりに手前で止まりすぎて陰影が変わるピクセルが、0.1 から先は単調に増えます(0.25% → 0.43% → 0.82% → 1.88%)。固定の行だけ 0.51% と 高いのは別の原因で、そこはすべてステップ切れで当たるはずが外れたピクセルです。

両方を同時に最小にする値はありません— 使い切りがいちばん少ないのは0.5以上(0.00%)、陰影のずれがいちばん少ないのは0.1(0.25%)で、2 つの最小は別の行にあります。0.25を採用したのは、使い切りが 0.1% を切る(0.06%)いちばん手前の行で、そこまで来れば陰影のずれも まだ 1% 未満(0.43%)だからです。この値での当たる位置のずれは、その距離での 1 ピクセルの足跡(短辺 270 px 換算)と比べて中央値 0.23 倍・90% 点 0.78 倍で、92.9% のピクセルが 1 ピクセル未満に収まります。1 ピクセルを超える残りの 7% は地平線に近い方向のレイに集まっています(この群のrd.yの平均は -0.10 で、全体の -0.31 よりずっと水平に近い)。ただし 1 ピクセルずれても陰影まで変わるとは限らず、目に見えて変わるのは上の表の右端の 0.43% です。

4. 3D の距離関数・合成・繰り返し

mapの中身に入ります。使う道具は第24章と同じで、次元が 1 つ増えるだけです。

src/lessons/28-raymarching/march.glsl(抜粋)
float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

第24章のsdBoxと見比べてください。vec2がvec3になり、max(q.x, q.y)がmax(q.x, max(q.y, q.z))になっただけで、式の構造は同一です。「外側は角までの距離、内側は最も近い面までの 符号付き距離」という理屈もそのままです。march.glslには球・箱・輪(トーラス)・平面を置きました。いずれも Inigo Quilez 氏の記事「distance functions」(iquilezles.org/articles/distfunctions/)が「exact な SDF」(近似ではなく厳密な距離)として挙げているものです。同記事は、この先頭のブロックだけが厳密で、Shadertoy などで見かける 実装には正しくない SDF も多いと注意しています。

合成も第24章のままです。sminは第24章のsdf.glslと 1 文字も違わない式を置いてあります(kは溶ける幅で、|a - b| >= kならminと一致し、a == bのときk / 4だけ落ち込む)。デモ 1 のシーンはこれだけです。

src/lessons/28-raymarching/01-steps.frag(抜粋)
float map(vec3 p) {
  float a = u_time * ORBIT_SPEED;
  float d = sdSphere(p - vec3(0.85 * cos(a), 0.0, 0.85 * sin(a)), 0.42);
  d = smin(d, sdBox(p, vec3(0.34)) - 0.06, BLEND_K);
  d = smin(d, sdTorus(p, vec2(1.15, 0.11)), BLEND_K);
  return min(d, sdPlane(p, vec3(0.0, 1.0, 0.0), 0.75));
}

球・箱・輪をsminで溶かし、床をminで足す。u_timeはmapの中から直接読めるので、球を回すのに uniform を増やす必要はありません(第4部のハーネスはu_time/u_resolution/u_mouseの 3 つだけ、というのが第24章で決めた約束です)。

折り畳みで無限に並べる — と、それが壊れるとき

第24章 6 節の折り畳みは、3D でも成分が増えるだけです。第24章ではmod版とround版の 2 通りを並べましたが、この章のコードは原点がセルの中心に来るround版だけを使います(形をセルの中央に置くのがこの節の主題なので、中心が原点に来るほうが素直です)。デモ 2 はxz平面に敷き詰めています。

src/lessons/28-raymarching/02-repeat.frag(抜粋)
  // セルの中での位置に畳む。第24章 6 節の round 版そのままで、成分が 1 つ増えただけ
  vec3 q = p;
  q.xz -= CELL * round(q.xz / CELL);
  q.xz -= g_offset * OFFSET_DIR;

第24章の落とし穴を思い出してください。折り畳みが返すのは「自分のセルにあるコピーまでの距離」だけです。本当に近いコピーが隣のセルにいると、距離は過大評価に なります。2D で塗るだけならセルの境目で形が切られて見えるだけでしたが、3 節で見たとおり レイマーチングでは過大評価が致命傷です。

デモ 2 の右側は、左とまったく同じ形をセルの中でずらしただけのものです。 ポインタを上下に動かすとずらし量が 0 から 0.42(セルの 32%)まで変わります。距離場の過大評価と、 レイが違う場所に当たる割合を測りました(セル 1.30・球の半径 0.40)。測り方は次のとおりです。

ずらし量距離の過大評価の最大(1 セル分の走査)当たる位置が変わったレイ(200×200)
0.00(中央)0.0000(厳密)0.00%
0.06(セルの 5%)0.11350.28%
0.12(セルの 9%)0.22672.96%
0.21(セルの 16%)0.39498.98%
0.30(セルの 23%)0.555118.92%
0.42(セルの 32%)0.748634.71%

過大評価の最大はずらした距離のおよそ 1.9 倍です(OFFSET_DIRは長さがほぼ 1 なので、ずらし量 0.06 なら距離も 0.06。表の 5 行で 1.90 / 1.89 / 1.89 / 1.86 / 1.79 倍)。理由は折り目の位置です。roundの折り畳みはセルの境目を ±CELL/2 に置きますが、形をずらすとコピーの「本当の中間線」もいっしょにずれます。ずらした距離をsとすると、この 2 本の線に挟まれた帯の中の点は自分のセルのコピーがsだけ遠く、本当に近いコピーがsだけ近いことになるので、差は2sに届きます。表の値が 2 倍より少し小さいのは、コピーが点ではなく斜めに並んだ立体だからです。 最大が出る場所も毎回セルの角(x=z= -CELL/2)でした。

条件は第24章とまったく同じです。畳んだ座標のどの点から見ても、自分のセルのコピーが最も近いこと。形をセルの中央に対称に置いていれば折り目と中間線が重なるので満たされ、ずらすとその 2 本がずれて壊れます。ずらし量 0 での過大評価がちょうど 0.0000 だったことが、この条件が満たされている印です(このとき march の結果も基準と 1 本のレイまで完全に一致するので、表の右の列も 0.00% になります)。

有限の繰り返しも第24章と同じで、番地をclampで止めるだけです。デモ 4 の柱は 4 × 4 = 16 本で、これで作っています。

src/lessons/28-raymarching/04-scene.frag(抜粋)
float dPillars(vec3 p) {
  vec2 id = clamp(round(p.xz / CELL - 0.5), LIMIT_LO, LIMIT_HI);
  vec2 cell = p.xz - CELL * (id + 0.5);
  return sdBox(vec3(cell.x, p.y + 0.08, cell.y), vec3(0.11, 0.62, 0.11)) - 0.04;
}

- 0.5しているのは、セルの中心を半セルずらして原点(画面中央)を空けるためです。 番地の範囲を-2〜1にすると、柱はセルの中心±0.5 * CELLと±1.5 * CELLに立ち、中央には何も来ません。

map の署名は変えられない

mapはmarch/calcNormal/softShadow/calcAOから呼ばれます。それらは共通チャンクの中にあるので、mapの形はチャンクの先頭に置いたプロトタイプ宣言で固定されています。

src/lessons/28-raymarching/march.glsl(抜粋)
float map(vec3 p);

GLSL ES 3.00 仕様 §6.1 は「All functions must be either declared with a prototype or defined with a body before they are called.」と定めています。つまり宣言だけ先に書いて、実体をあとに置くのは合法です。同じ関数のプロトタイプと定義を両方書いてよいことは §12.6 でも明示されています (「Multiple function declarations (function prototypes) are allowed.」)。これで共通部分は 1 か所に置いたまま、シーンだけ各デモが差し替える形になります。

その代わり、mapに引数を増やして「区画ごとに違うシーン」を渡すことはできません。デモ 2 はグローバル変数で渡しています。

src/lessons/28-raymarching/02-repeat.frag(抜粋)
// 区画ごとに map の中身を変えたいが、map の引数は増やせない
// (march / calcNormal がプロトタイプどおりに呼ぶ)。そこでグローバル変数で渡す
float g_offset = 0.0;

仕様 §4.3 は、記憶域修飾子を持たないグローバル変数の初期化子は定数式でなければならないと定めています(0.0は定数式なので問題ありません)。値はmainの中で区画に応じて書き換えます。

もう 1 つの副作用は材質です。mapが返すのはfloat1 つなので、「どの部品に当たったか」はどこにも残りません。デモ 4 は当たった点で部品ごとの距離を 測り直し、いちばん近いものの色を使っています。

src/lessons/28-raymarching/04-scene.frag(抜粋)
vec3 albedoAt(vec3 p) {
  float a = dFloor(p);
  float b = dPillars(p);
  float c = dBody(p);
  if (a < b && a < c) {
    return FLOOR_SRGB;
  }
  return b < c ? PILLAR_SRGB : BODY_SRGB;
}

素材 ID をvec2に詰めてmapから返す書き方もよく使われますが、そうするとプロトタイプが変わり、共通チャンクの側も全部vec2にする必要があります。この章は「距離だけを返す」形で通し、材質は当たったあとに決めています。

5. 法線を数値微分で作る

ライティングには法線が必要です。第3部では属性として送っていました(第13〜14章で生成し、第17章で 法線行列を掛けた)。ここには頂点がありません。かわりに距離場の勾配を使います。

第24章 1 節で、距離場の勾配∇dは「最も急に値が増える向き」で、その大きさは 1 だと確かめました。表面上でその向きは面から外へ真っすぐ、つまり法線そのものです。あとは勾配を数値的に取るだけ。中心差分を使います。

src/lessons/28-raymarching/march.glsl(抜粋)
vec3 calcNormal(vec3 p, float eps) {
  vec2 h = vec2(eps, 0.0);
  return normalize(vec3(
      map(p + h.xyy) - map(p - h.xyy),
      map(p + h.yxy) - map(p - h.yxy),
      map(p + h.yyx) - map(p - h.yyx)));
}

h.xyyはvec3(eps, 0, 0)、h.yxyはvec3(0, eps, 0)、h.yyxはvec3(0, 0, eps)です(第5章のスウィズル)。各軸について「少し進んだ値」と「少し戻った値」の差を取るので、mapの評価は6 回。本来は2 * epsで割るべきですが、直後にnormalizeするので割る必要はありません。この形は Inigo Quilez 氏の記事「normals for an SDF」(iquilezles.org/articles/normalsSDF/)にあるものです。

6 回か 4 回か

中心の値を 1 回だけ取って 3 方向へ 1 歩ずつ進む前進差分なら、評価は 4 回で 済みます。同記事も「6 回が高すぎるなら」として挙げています。

前進差分版(説明用。この章のデモでは使っていません)
// 前進差分版。中心の値を 1 回だけ取り、そこから 3 方向へ 1 歩ずつ。
// 評価は 4 回で済むが、サンプル点が正の側に偏るぶん面がわずかにずれる
vec3 calcNormalForward(vec3 p, float eps) {
  float d = map(p);
  vec2 h = vec2(eps, 0.0);
  return normalize(vec3(
      map(p + h.xyy) - d,
      map(p + h.yxy) - d,
      map(p + h.yyx) - d));
}

精度の差を測りました。デモ 3 のシーンの表面上 10553 点について、サンプル座標を float32 に丸めたmapで作った法線と、float64 の高精度な中心差分(eps= 1e-6)で作った法線の角度差の平均です(丸めるのは座標です。表面上でのmapの戻り値は 3e-3 前後で、そこでの float32 の刻みは 2e-10 しかないので、桁が落ちるのは座標の側です)。

eps中心差分(6 回)前進差分(4 回)四面体(4 回)
1e-40.0006°0.0042°0.0041°
1e-30.0002°0.0395°0.0398°
1e-20.0195°0.3918°0.4029°

epsが大きくなるほど差が開きます(1e-2 では 20 倍)。前進差分はサンプル点が正の側に偏るので、面が わずかにずれ、そのずれがepsに比例して育つからです。上の記事はもう 1 つ、四面体の 4 頂点でサンプルする方法(評価 4 回で、サンプル点は正負に均等)を紹介しています。同記事によれば、氏はこの方法を 2008 年ごろ Pouet の Paulo Falcao 氏の作例で最初に見て、のち Shadertoy の Paul Malin 氏も 使っており、以後ほとんどのシェーダーで採用しているそうです。上の表の右端はその式を移植して 測ったもので、精度は前進差分とほぼ同じでした。理由は誤差の次数です。四面体の 4 点はp + (±1, ±1, ±1) * epsの形で、Taylor 展開すると 1 次の項(勾配そのもの)はきれいに取り出せますが(Σ k_jx² = 4・Σ k_jx k_jy = 0)、2 次の項のうち交差微分だけが打ち消し残ります(Σ k_jx k_jy k_jz = 4 ≠ 0なので、x 成分は4 eps · ∂f/∂x + 4 eps² · ∂²f/∂y∂z + …)。相対誤差はepsの 1 乗で増える — 前進差分と同じ次数です。中心差分は 2 次の項がまるごと消える のでepsの 2 乗になります。上の表の 1e-3 → 1e-2 で、中心差分が約 100 倍(0.0002° → 0.0195°)、他の 2 つが約 10 倍(0.0395° → 0.3918° / 0.0398° → 0.4029°)に増えているのがそれです(中心差分の 1e-4 の 行だけ 1e-3 より大きいのは、そこでは丸めの誤差が下限を作っているからです)。偏りを消しても、この次数は上がりません。

この章のデモは 6 回の中心差分を使っています。デモ 4 では 1 ピクセルあたりのmap評価回数のうち法線が占めるのは 4.09 回(全体の 11.5%)で、4 回に減らしても効果は限られるからです。

刻み幅には上限と下限がある

デモ 3 はポインタ上下でepsを 10⁻⁷ から 10⁻¹ まで動かせます。両端で壊れます(測り方は上の表と同じで、mapのサンプル座標を float32 に丸め、基準は float64・eps= 1e-6 の中心差分です。丸めるのが座標の側でないと、小さいepsでの桁落ちは再現しません)。

eps法線の角度誤差(平均)同(最大)中心差分から出る |∇d|
1e-70.7071°15.15°0.2256〜1.1923
1e-60.0579°1.4168°0.2537〜1.0133
1e-50.0059°0.1383°0.2516〜1.0014
1e-40.0006°0.0128°0.2513〜1.0002
1e-30.0002°0.2088°0.2513〜1.0000
1e-20.0195°2.7194°0.2514〜1.0016
1e-11.1267°30.90°0.2606〜1.0053

小さすぎる側の原因は桁落ちです。float32 の相対精度は2⁻²⁴ = 5.96e-8で、値が 1 前後のとき隣の float32 との差は1.19e-7。epsが 1e-7 だと、引き算する 2 点の座標p + hとp - hの差(2 * eps= 2e-7)がその刻み 1〜2 個ぶんしかありません。軸ごとに違う量へ丸められるので、 差の向きが丸めで決まってしまい、法線が暴れます(mapの戻り値のほうは表面上で 3e-3 前後、刻みは 2e-10 なので余裕があります — 桁が落ちるのは座標の側です)。大きすぎる側は素直に「離れた 2 点の平均の傾き」を測って しまうためで、角や溝が鈍り、細部が溶けます。この配置では 1e-4〜1e-3 が最も良く、 デモの中央(ポインタ未操作)はちょうど 1e-4 です。

デモ 4 ではepsを距離に比例させています。

src/lessons/28-raymarching/04-scene.frag(抜粋)
    vec3 n = calcNormal(pos, 0.0006 * (1.0 + hit.x));

理由は 3 節のヒット判定と同じです。上記の記事も、hは「そのサンプル点でのピクセルの足跡(pixel footprint)の大きさ、つまりカメラからの距離tに比例する量」にすべきだと述べ、遠景の崖のちらつきが収まる比較を示しています。細かすぎる差分は、 画面に描けない細部を拾ってちらつきの元になります。

6. 影とアンビエントオクルージョン

第17章 6 節で、環境光は「どこからでも同じだけ光が来ていることにする」乱暴な近似だと書き、くぼんだ場所や物どうしの隙間が暗くならないという限界を挙げました。第21章では シャドウマップで物体どうしの影を作り、その 10 節で「影を掛けるのは直接光だけ。環境光に掛けると 影の中が真っ黒になる」と決めました。どちらの伏線も、この節で回収します。

距離場を持っていると、この 2 つがどちらもレイをもう 1 本進めるだけで手に入ります。 シャドウマップも深度テクスチャも要りません。

ソフトシャドウ

表面の点から光源の方向へもう 1 本 sphere tracing します。途中で何かに当たれば 影(0)、最後まで当たらなければ日向(1)。それだけなら硬い影ですが、1 行足すだけで半影 (penumbra)が付きます。

src/lessons/28-raymarching/march.glsl(抜粋)
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, int maxSteps, float k) {
  float res = 1.0;
  float t = mint;
  for (int i = 0; i < maxSteps; i++) {
    float h = map(ro + rd * t);
    if (h < 0.001) {
      return 0.0;
    }
    res = min(res, k * h / t);
    t += h;
    if (t > maxt) {
      break;
    }
  }
  return res;
}

肝はres = min(res, k * h / t)です。hは「その点での、いちばん近い表面までの距離」= 当たりかけた度合いで、tは「影を受ける点からそこまでの距離」です。近くでかすったほど暗く、遠くでかすったほど薄い— これを全ステップで取って最小値を残します。出典は Inigo Quilez 氏の「soft shadows in raymarched SDFs」(iquilezles.org/articles/rmshadows/)で、記事はこの式をshadow ∝ closest_miss / distance_to_closest_missと説明しています。同記事によれば公開は 2010 年です。

kは影の硬さを決めます。記事は「大きいほど影が硬くなるのだから、光源の大きさ(立体角)の逆数に対応しているのではないか」という推論を述べ、レイトレーシングの正解との比較図でそれを 裏づけています。実際に半影の幅を測ると、きれいに1 / kに比例しました(半径 0.5 の球 1 個・床はy = -1.5・真上からの光。影の係数が 0.05 から 0.95 になるまでの幅)。

k4816224080
半影の幅0.35950.16900.08450.06100.03350.0170

kが 2 倍になるたびに幅がほぼ半分です。厳密に言うとk= 8 以上ではきれいに1 / kで(幅 × kが 1.33〜1.35 で揃う)、いちばん柔らかいk= 4 だけ 7% ほど外れます(幅 × k= 1.44)。半影が球の半径に対して大きくなり、遮蔽物の大きさそのものが効いてくる領域です。デモ 4 はポインタ上下でkを 4 から 40 まで動かせます。下へ持っていくと、大きな光源の下にいるような柔らかい影になります。

観点第21章 シャドウマッピングこの節
必要なもの深度テクスチャ + 光源視点の 2 パス目何も要らない。同じ map をもう一度舐めるだけ
コストの増え方シーンをもう一度描く(ドローコールと頂点処理が光源の数だけ増える)1 ピクセルあたりのループが増える(デモ 4 では当たったピクセル 1 つにつき平均 11.5 回のmap評価)
解像度の影響シャドウマップのテクセルの粗さがそのまま影のふちに出るテクスチャが無いので、影の解像度という概念が無い
半影PCF は同じ幅でぼかすだけ。遮蔽物との距離で変えるには PCSS などが必要h / tが距離を含むので、接地部分は鋭く、離れるほど柔らかくなる
シャドウアクネ出る。連続した面をテクセルという階段で記録するため(第21章 7 節)出ない。深度を格子に量子化していないので、階段が存在しない
ピーターパン現象バイアスを入れすぎると影が本体から離れる(第21章 8 節)相当する現象は起きる。レイの開始位置mintを大きくすると、接地部分の遮蔽を飛び越してしまう

最後の 2 行を確かめておきます。アクネが出ないのは、この方式が深度を格子に記録していないからです。 第21章の縞は「テクセルごとに 1 つの深度」という階段が原因でした。ここには階段が無いので、 自己遮蔽は「レイの出発点が面に近すぎて、いきなりh < 0.001になる」という形でしか現れず、それはmintと法線方向への逃がし(SHADOW_BIAS)で防げます。しかもdot(N, L)が 0 に近い浅い入射では拡散項そのものが 0 なので、絵に出ません。

一方、mintを大きくすると接地の影が抜けます。デモ 4 の台座の縁の外側 40 点で影の係数の 平均を測りました。

mint0.010.03(採用)0.100.150.200.60
影の係数の平均0.24780.24770.24770.32270.55480.5546

0.10 までは何も変わりませんが、0.20 では影が半分以上抜けています。レイが台座の縁を飛び越えて しまうからで、第21章のピーターパン現象と原因まで同じ(「自分は本当の位置より 少し先にいることにする」という嘘の代償)です。0.03 を採用したのは、影が抜け始める手前(表の平らな 範囲)で、しかも自己遮蔽で真っ黒にならないだけの余裕を取った値だからです。 小さくし過ぎると、上に書いた「出発点が面に近すぎて、いきなりh < 0.001になる」面が出ます。上限と下限の両方があるのは、epsのときと同じ構図です。

アンビエントオクルージョン

アンビエントオクルージョン(以下 AO) は、「周りをどれくらい物に囲まれているか」を表す 0〜1 の値です。1 が開けた場所、0 が塞がれた場所。これを環境光に掛ければ、第17章で挙げた「隙間が暗くならない」限界が埋まります。

距離場での作り方は、驚くほど短く書けます。法線方向へ少しずつ離れた点で 距離場を測るだけ。何も無ければ、法線方向へL進んだ点での距離はLちょうどのはずです(表面から真っすぐ離れているので)。それより小さければ、その差のぶんだけ「何かが近い」ということになります。

開けた場所map = 1Δmap = 3Δmap = 5Δ期待どおり → 遮蔽 0隣に壁があるmap < 3Δmap < 5Δ差の重み付き和 = 遮蔽の量
法線方向へΔ刻みで 5 点。開けた場所ではi番目の点での距離がちょうどi * Δになります。近くに何かがあると、距離はそちらまでの値に切り詰められて期待値より小さくなる。 その差を、遠い点ほど軽くして足し上げたものが遮蔽の量です。
src/lessons/28-raymarching/march.glsl(抜粋)
float calcAO(vec3 p, vec3 n, float delta, float k) {
  float occ = 0.0;
  float weight = 0.5; // 1 / 2^i
  for (int i = 1; i <= 5; i++) {
    float len = float(i) * delta;
    occ += weight * (len - map(p + n * len));
    weight *= 0.5;
  }
  return clamp(1.0 - k * occ, 0.0, 1.0);
}

これは上でも引いた nvscene 2008 の資料に載っている式そのもので、資料にはao = 1 - k · Σ(i = 1..5) (1 / 2ⁱ)(i·Δ - distfield(p + n·i·Δ))と書かれています。1 / 2ⁱの重みについて資料は「遠い面のほうが遮蔽への効きが小さくなるように、指数的に減衰させて いる」と説明しています。評価回数は5 回で固定です。同資料は、通常の レイトレーサでは AO のために何千本ものレイを撃つのに対し、この方法は 5 回の距離評価で済む、と 書いています。

デモ 4 の設定(Δ= 0.12・k= 4.0)で実際に出る AO の値は0.0565〜1.0000でした。柱の根元や、球と輪が溶け合った谷が暗くなります。そして掛ける相手は環境光だけです。

src/lessons/28-raymarching/04-scene.frag(抜粋)
    float diffuse = max(dot(n, LIGHT_DIRECTION), 0.0);
    float shadow = softShadow(
        pos + n * SHADOW_BIAS, LIGHT_DIRECTION, SHADOW_MIN_T, SHADOW_MAX_T, SHADOW_STEPS, shadowK);
    float ao = calcAO(pos, n, AO_DELTA, AO_STRENGTH);

    // 影は直接光にだけ掛け、AO は環境光にだけ掛ける(第21章 10 節・第17章 6 節)
    vec3 direct = srgbToLinear(SUN_SRGB) * SUN_INTENSITY * diffuse * shadow;
    vec3 ambient = sky * AMBIENT * ao;
    color = albedo * (direct + ambient);

第21章 10 節の約束(影は直接光に掛ける)と、第17章 6 節の限界(環境光は隙間を暗くしない)が、この 3 行で同時に片付きます。影 = 直接光の遮蔽、AO = 環境光の遮蔽。役割が きれいに分かれています。

7. フォグと仕上げ

最後の仕上げです。まず距離フォグ。レイマーチングではtがそのままカメラからの距離なので、深度バッファから距離を復元する手間もありません。

src/lessons/28-raymarching/04-scene.frag(抜粋)
    // 距離フォグ。奥へ行くほど空の色に置き換わる
    color = mix(sky, color, exp(-hit.x * FOG_DENSITY));
  }

  // 画面へ出す最後の 1 行。ここまでずっとリニア空間で計算してきた(第27章 2 節)
  fragColor = vec4(linearToSrgb(color), 1.0);

exp(-t * k)は「一様な濃さの霧を距離tだけ通ったときに、遮られずに届く割合」です。同じ濃さの層をいくつ通っても割合が掛け算になる — という性質が指数関数そのものなので、この形になります。ついでにフォグは最大距離で切れる境目を隠してくれます。MAX_DISTまで進んで諦めたレイは背景色になりますが、その手前が十分に霧で薄まっていれば、境目は目に つきません。

そして色空間です。この章のライティングは足し算と掛け算で光の量を積み上げる 計算なので、絵として見せるならリニア空間で行う必要があります。デモ 4 は次の 3 段構えにしています。

  1. 色の定数は読みやすさのためsRGB のまま書く(SKY_LOW_SRGB/BODY_SRGBなど)
  2. 使う直前にsrgbToLinearでリニアへ直す
  3. すべての計算が終わってから、出力の最後の 1 行でlinearToSrgbで戻す
src/lessons/28-raymarching/04-scene.frag(抜粋)
  vec3 sky = srgbToLinear(mix(SKY_LOW_SRGB, SKY_HIGH_SRGB, smoothstep(-0.1, 0.6, rd.y)));

この往復を通しているのはデモ 4 だけです。デモ 1 の色は「ステップ数を見るための カラーマップ」で光の量ではありませんし、デモ 2・3 の陰影は形と法線を見るための目安で、絵の 正しさを問う場面ではありません。だからこの 3 本は画面に出る sRGB 値を直接書くと決めました(第27章 3 節の言い方です)。とくにデモ 3 の左半分は法線をそのまま色にした可視化なので、往復を 通す相手ではありません。1 本のシェーダーの中で半分だけ変換を通すと、どちらの流儀なのかが 読めなくなります。第27章 3 節が書いたとおり、決めないことだけが問題なので、 決めたことを本文と各.fragのコメントに書いてあります。光の量として足し引きするデモ 4 だけが、入口と出口の 2 か所で変換を通ります。

この 2 つの関数は第27章 2 節で導いた正確な sRGB 伝達関数です。この章のcolor.glslは、第27章のcolor.glslからsrgbToLinear/linearToSrgbの2 関数だけを複製したものです(その 2 関数はヘッダーコメント以外 1 文字も違いません — 1066 バイトでバイト一致。第27章のファイルにはトーンマッピングや余弦パレットも 入っているので、ファイル全体としては別物です)。変換の理屈と、どこで変換すべきかは第27章 2 節と 3 節が担当なので、ここでは繰り返しません。露出とトーンマッピングは第27章 5 節の話で、この章では使っていません。値が 1 を超える場面を作っていないからです(SUN_INTENSITY= 1.15・AMBIENT= 0.34 で、アルベドは 1 未満。480×270・ポインタ中央で出力を走査すると、リニア値の最大は 0.49 でした)。linearToSrgbの中のclampは仕様の式の「0 以下と 1 以上を切り落とす」2 本の枝にあたるもので、この章では何も切り落として いません。

光の向きは第17章の約束どおり面から光源へ向かう単位ベクトルで、計算はワールド 空間です。この章に uniform を増やす余地はないので、ファイル冒頭のconstに置いています。

仕上げまで通したときのmapの評価回数の内訳が、この章のコストの全体像です(480×270・ポインタ中央・u_time= 0 で、当たったピクセルは 68.2%)。

用途1 ピクセルあたりの map 評価回数割合当たったピクセル 1 つあたり
primary ray(3 節)20.0856.7%—(外れたピクセルも消費する)
ソフトシャドウ(6 節)7.8622.2%11.5(ループなので可変)
法線(5 節)4.0911.5%6(固定)
AO(6 節)3.419.6%5(固定)
合計35.43100%—

半分以上が primary ray です。影と AO と法線を全部足しても、レイを飛ばして表面を見つける コストのほうが大きい。第26章でpcg3dの呼び出し回数でコストを語ったのと同じ流儀で、これはシェーダーを読めば数えられる量です。実際の GPU 時間ではありません。

コード全文

4 本のシェーダーは共通のチャンクを#includeで取り込み、main.tsの文字列置換で解決しています。仕組みは第23章のpbr.glsl・第24章のsdf.glslと同じです。

src/lessons/28-raymarching/main.ts(抜粋)
function resolveIncludes(source: string): string {
  return source
    .replace('#include "color.glsl"', () => colorChunkSource.trim())
    .replace('#include "march.glsl"', () => marchChunkSource.trim());
}
src/lessons/28-raymarching/main.ts(抜粋)
// この章だけ dpr の上限を 1.5 に下げている(既定は 2)。
// 全画面レイマーチングは 1 ピクセルあたり map() を数十回評価するので、
// コストがピクセル数にそのまま比例する。dpr 2 → 1.5 でピクセル数は
// (1.5 / 2)^2 = 0.5625 倍になる。先例は第23章(ディファードの G-buffer)
const MAX_DPR = 1.5;

比較デモ(デモ 2・デモ 3)が画面を左右 2 区画に分ける仕掛け(標準座標の 1 行を区画の幅で使い直す)は 2 節の末尾で見たとおりです。

src/lessons/28-raymarching/march.glsl
// 第28章の 4 本のデモが共有するチャンク。
// 各 .frag に書いた include 行を main.ts が文字列置換でこのファイルの中身に
// 差し替える(仕組みは第23章の pbr.glsl・第24章の sdf.glsl と同じ)。
//
// シーンの距離関数 map(vec3) は、このファイルではなく各 .frag が定義する。
// GLSL ES 3.00 は「呼ぶ前にプロトタイプか定義が必要」(仕様 §6.1)なので、
// ここではプロトタイプだけ置いて実体をあとから書く。プロトタイプと定義の
// 両方を書くのは合法(仕様 §12.6「多重の関数宣言(プロトタイプ)は許す」)。
float map(vec3 p);

// ---- 3D の距離関数 -----------------------------------------------------
// Inigo Quilez「distance functions」(iquilezles.org/articles/distfunctions/)が
// 「exact な SDF」として挙げているものをそのまま使う。第24章の 2D 版と
// 見比べると、sdBox は max が 1 段深くなっただけで式の形は同じ。

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

// t.x = 輪の半径、t.y = 管の半径。xz 平面に寝た輪になる
float sdTorus(vec3 p, vec2 t) {
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

// n は単位ベクトル、h は原点から面までの符号付きの距離。
// n = (0, 1, 0) / h = 0.75 なら「y = -0.75 の水平な床」になる
float sdPlane(vec3 p, vec3 n, float h) {
  return dot(p, n) + h;
}

// 第24章の smin と同じ式(1 文字も変えていない)。k は溶ける幅で、
// |a - b| >= k なら min と一致し、a == b のとき min より k / 4 だけ小さい
float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

// ---- カメラ ------------------------------------------------------------

// 球面座標 → カメラ位置。第15章の軌道カメラとまったく同じ式で、
// この章は第15章の流儀(水平面から測る仰角 phi・+z から測る方位角 theta)を採る
vec3 orbitEye(vec3 target, float theta, float phi, float radius) {
  return target + radius * vec3(cos(phi) * sin(theta), sin(phi), cos(phi) * cos(theta));
}

// カメラの基底を列に並べた mat3。中身は第12章の lookAt と同じ cross 2 回で、
// 列 0 = 右、列 1 = 上、列 2 = 前(視線の向き)。
// back は第12章の zAxis、right は xAxis、up は yAxis にそのまま対応する
mat3 cameraBasis(vec3 eye, vec3 target) {
  vec3 back = normalize(eye - target);
  vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), back));
  vec3 up = cross(back, right); // 直交する単位ベクトル同士の cross なので正規化は不要
  return mat3(right, up, -back);
}

// 標準座標 p(短辺が -1〜1)を「センサー上の位置」と読み替えてレイの向きを作る。
// focal = 1 / tan(fov / 2) にすると、短辺方向の画角がちょうど fov になる
vec3 cameraRay(mat3 basis, vec2 p, float focal) {
  return normalize(basis * vec3(p, focal));
}

// ---- sphere tracing ----------------------------------------------------

// 距離のぶんだけ進む前進。名前の由来は John C. Hart, "Sphere tracing: a
// geometric method for the antialiased ray tracing of implicit surfaces",
// The Visual Computer 12, 527-545 (1996)。
// 戻り値 x = 当たった距離 t(外れたら -1.0)、y = 消費したステップ数
vec2 march(vec3 ro, vec3 rd, int maxSteps, float maxDist, float eps, float epsSlope) {
  float t = 0.0;
  for (int i = 0; i < maxSteps; i++) {
    float d = map(ro + rd * t);
    // ヒットのしきい値を t に比例させる。遠いほど 1 ピクセルの足跡が広いので、
    // 近くと同じ絶対値まで詰めるのは無駄になる。epsSlope = 0.0 にすれば
    // 固定のしきい値に戻る(比例の効き方の実測は本文 3 節)
    if (d < eps * (1.0 + epsSlope * t)) {
      return vec2(t, float(i + 1));
    }
    t += d;
    if (t > maxDist) {
      return vec2(-1.0, float(i + 1));
    }
  }
  return vec2(-1.0, float(maxSteps));
}

// ---- 法線 --------------------------------------------------------------

// 中心差分による法線。map を 6 回評価する(本文 5 節)。
// 出典: Inigo Quilez「normals for an SDF」(iquilezles.org/articles/normalsSDF/)
vec3 calcNormal(vec3 p, float eps) {
  vec2 h = vec2(eps, 0.0);
  return normalize(vec3(
      map(p + h.xyy) - map(p - h.xyy),
      map(p + h.yxy) - map(p - h.yxy),
      map(p + h.yyx) - map(p - h.yyx)));
}

// ---- 影 ----------------------------------------------------------------

// ソフトシャドウ。表面から光源方向へもう 1 本 sphere tracing し、
// 「当たりかけた度合い」h / t の最小値を遮蔽率にする。
// 出典: Inigo Quilez「soft shadows in raymarched SDFs」
// (iquilezles.org/articles/rmshadows/)のオリジナル版。同記事によれば
// この式の公開は 2010 年で、k は光源の大きさ(立体角)の逆数に相当し、
// 大きいほど影が硬くなる
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, int maxSteps, float k) {
  float res = 1.0;
  float t = mint;
  for (int i = 0; i < maxSteps; i++) {
    float h = map(ro + rd * t);
    if (h < 0.001) {
      return 0.0;
    }
    res = min(res, k * h / t);
    t += h;
    if (t > maxt) {
      break;
    }
  }
  return res;
}

// ---- アンビエントオクルージョン ----------------------------------------

// 法線方向へ delta ずつ離れた 5 点で距離場を測り、「何も無ければ i * delta の
// はず」との差を、遠い点ほど半分ずつ軽くして積む。map の評価回数は 5 回で固定。
// 出典: Inigo Quilez「Rendering Worlds With Two Triangles」(nvscene 2008 の
// 発表資料 iquilezles.org/articles/nvscene2008/rwwtt.pdf・47〜53 枚目)。
// 同資料は元のアイデアを Alex Evans, "Fast Approximations for Global
// Illumination on Dynamic Scenes"(SIGGRAPH 2006 コースノート
// "Advanced Real-Time Rendering in 3D Graphics and Games" 第 9 章)に帰している
float calcAO(vec3 p, vec3 n, float delta, float k) {
  float occ = 0.0;
  float weight = 0.5; // 1 / 2^i
  for (int i = 1; i <= 5; i++) {
    float len = float(i) * delta;
    occ += weight * (len - map(p + n * len));
    weight *= 0.5;
  }
  return clamp(1.0 - k * occ, 0.0, 1.0);
}
src/lessons/28-raymarching/color.glsl
// 第28章 デモ4 が使う色変換。第27章 color.glsl から srgbToLinear / linearToSrgb の
// 2 関数だけを複製したもの(この 2 関数はヘッダーコメント以外 1 文字も違わない。
// 第27章のファイルにはトーンマッピングと余弦パレットも入っているので、
// ファイル全体としては別物)。この章では「ライティングをリニア空間で計算し、
// 出す直前に sRGB へ戻す」ためだけに使う。変換の理屈と伝達関数の出どころは第27章 2 節。
// なおデモ1〜3 はこの往復を通さず、画面に出る sRGB 値を直接書くと決めている(本文 7 節)。

/**
 * sRGB エンコード値 → リニア値。
 * 出典: OpenGL ES 3.0.6 §3.8.16 式 (3.26)。GPU が sRGB テクスチャを読むときの変換そのもの。
 * 境界 (0.04045) では 2 つの式の差が 2.3e-9 しかないので、等号がどちら側かは問題にならない。
 */
vec3 srgbToLinear(vec3 c) {
  vec3 lo = c / 12.92;
  vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
  return mix(lo, hi, step(vec3(0.04045), c));
}

/**
 * リニア値 → sRGB エンコード値。画面へ出す最後の 1 行はこれになる。
 * 出典: OpenGL ES 3.0.6 §4.1.8 式 (4.1)。仕様は指数を 0.41666 と書いているが、
 * これは 1/2.4 = 0.4166666… を打ち切った値で、8 ビット出力での差は最大 1 段。1/2.4 を使う。
 * 仕様の式は 0 以下と 1 以上を切り落とすので、clamp がその 2 本の枝にあたる。
 */
vec3 linearToSrgb(vec3 c) {
  c = clamp(c, 0.0, 1.0);
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}
src/lessons/28-raymarching/01-steps.frag
#version 300 es

// 第28章 デモ1: sphere tracing のステップ数を色で見る。
// 各ピクセルから 1 本レイを飛ばし、map の値だけ進む前進を繰り返して、
// ヒットするまでに何ステップ使ったかを色にした。形は塗っていない。
//   暗い青 = 数ステップで済んだ / 明るいほど多い(MAX_STEPS を使い切ると純白)
// シルエットの縁と、床を浅い角度でなめる方向でステップ数が跳ね上がるのが見どころ。
// ポインタ: 左右で方位角、上下で仰角(カメラの軌道)。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "march.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 1 本のレイに許すステップ数。下げるとそのぶん軽くなり、白い領域が広がる
const int MAX_STEPS = 128;
// これより遠くまで進んだら「何にも当たらなかった」とみなす
const float MAX_DIST = 24.0;
// ヒットのしきい値の基準値と、距離に対する傾き。
// march の中で eps * (1.0 + epsSlope * t) として使われる
const float EPS = 0.0015;
const float EPS_SLOPE = 0.25;
// 短辺方向の画角(度)。第3部のカメラ標準は 45 度だった
const float FOV = 45.0;
// カメラの注視点と軌道半径
const vec3 TARGET = vec3(0.0, -0.10, 0.0);
const float RADIUS = 4.2;
// 球が本体のまわりを回る速さ
const float ORBIT_SPEED = 0.35;
// 溶ける幅(第24章の smin の k)
const float BLEND_K = 0.35;
// ----------------------------------------------------------------------

// このデモのシーン。球・箱・輪を smin で溶かし合わせ、床を min で足す
float map(vec3 p) {
  float a = u_time * ORBIT_SPEED;
  float d = sdSphere(p - vec3(0.85 * cos(a), 0.0, 0.85 * sin(a)), 0.42);
  d = smin(d, sdBox(p, vec3(0.34)) - 0.06, BLEND_K);
  d = smin(d, sdTorus(p, vec2(1.15, 0.11)), BLEND_K);
  return min(d, sdPlane(p, vec3(0.0, 1.0, 0.0), 0.75));
}

// ステップ数 0〜1 を色に写す。暗い青 → 青 → 黄 → 白。
// この色は光の量ではなく診断用のカラーマップなので、画面に出る sRGB 値として
// 直接選んでいる(リニア空間との往復は通さない。第27章 3 節・本文 7 節)
vec3 stepsColor(float t01) {
  vec3 c = mix(vec3(0.05, 0.07, 0.16), vec3(0.20, 0.58, 0.85), smoothstep(0.0, 0.35, t01));
  c = mix(c, vec3(0.95, 0.74, 0.24), smoothstep(0.32, 0.72, t01));
  return mix(c, vec3(1.0, 0.98, 0.94), smoothstep(0.72, 1.0, t01));
}

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // ポインタで軌道を動かす。上限の 0.85 rad(約 49 度)は構図で決めた値。真上(pi/2)へ
  // 寄せると cameraBasis の cross が退化するので、そもそも近づけない(第15章と同じ理由。
  // 第15章の PHI_LIMIT は pi/2 - 0.05 で、あちらは限界の ε 手前まで許している)。
  // 下限はカメラが床(y = -0.75)より十分上に来るところで止める
  vec2 mouse01 = u_mouse / u_resolution;
  float theta = mix(-1.1, 1.1, mouse01.x);
  float phi = mix(0.03, 0.85, mouse01.y);

  vec3 eye = orbitEye(TARGET, theta, phi, RADIUS);
  mat3 basis = cameraBasis(eye, TARGET);
  vec3 rd = cameraRay(basis, p, 1.0 / tan(radians(FOV) * 0.5));

  vec2 hit = march(eye, rd, MAX_STEPS, MAX_DIST, EPS, EPS_SLOPE);
  vec3 color = stepsColor(hit.y / float(MAX_STEPS));

  fragColor = vec4(color, 1.0);
}
src/lessons/28-raymarching/02-repeat.frag
#version 300 es

// 第28章 デモ2: 3D の繰り返しと、それが壊れるとき。
// 画面を左右 2 区画に分け、まったく同じシーンを同じカメラから描く。
//   左 = 形をセルの中央に置いた繰り返し   → 距離は過大評価にならず、絵は破綻しない
//   右 = 同じ形をセルの中でずらした繰り返し → 距離が過大評価になり、レイが面を突き抜ける
// ポインタ: 左右で方位角、上下で右区画のずらし量(いちばん下で 0 = 左と同じ)。

precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "march.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
const int MAX_STEPS = 96;
const float MAX_DIST = 24.0;
const float EPS = 0.0015;
const float EPS_SLOPE = 0.25;
const float FOV = 45.0;
const vec3 TARGET = vec3(0.0, -0.15, 0.0);
const float RADIUS = 4.6;
// カメラの仰角(このデモは固定)
const float PHI = 0.30;
// セルの一辺。xz 平面に敷き詰める
const float CELL = 1.30;
// セルの中の球の半径。CELL / 2 より小さいので、形はセルからはみ出さない
const float BALL = 0.40;
// ずらす向き。ポインタ上下でこの向きに 0〜MAX_OFFSET だけずらす
const vec2 OFFSET_DIR = vec2(0.88, 0.47);
const float MAX_OFFSET = 0.42;
// 溶ける幅
const float BLEND_K = 0.22;
// 面から光源へ向かう単位ベクトル(第17章の約束)
const vec3 LIGHT_DIRECTION = normalize(vec3(0.45, 0.80, 0.40));
// ----------------------------------------------------------------------

// 区画ごとに map の中身を変えたいが、map の引数は増やせない
// (march / calcNormal がプロトタイプどおりに呼ぶ)。そこでグローバル変数で渡す
float g_offset = 0.0;

float map(vec3 p) {
  // セルの中での位置に畳む。第24章 6 節の round 版そのままで、成分が 1 つ増えただけ
  vec3 q = p;
  q.xz -= CELL * round(q.xz / CELL);
  q.xz -= g_offset * OFFSET_DIR;

  float d = sdSphere(q - vec3(0.0, -0.18, 0.0), BALL);
  d = smin(d, sdBox(q - vec3(0.0, 0.26, 0.0), vec3(BALL * 0.52)), BLEND_K);
  return min(d, sdPlane(p, vec3(0.0, 1.0, 0.0), 0.75));
}

void main() {
  // 区画は左右 2 枚。標準座標の 1 行を「区画の幅」で使い直す
  float halfWidth = u_resolution.x * 0.5;
  bool right = gl_FragCoord.x > halfWidth;
  vec2 origin = vec2(right ? halfWidth : 0.0, 0.0);
  float side = min(halfWidth, u_resolution.y);
  vec2 p = ((gl_FragCoord.xy - origin) * 2.0 - vec2(halfWidth, u_resolution.y)) / side;

  vec2 mouse01 = u_mouse / u_resolution;
  g_offset = right ? MAX_OFFSET * mouse01.y : 0.0;

  float theta = mix(-1.2, 1.2, mouse01.x);
  vec3 eye = orbitEye(TARGET, theta, PHI, RADIUS);
  mat3 basis = cameraBasis(eye, TARGET);
  vec3 rd = cameraRay(basis, p, 1.0 / tan(radians(FOV) * 0.5));

  vec2 hit = march(eye, rd, MAX_STEPS, MAX_DIST, EPS, EPS_SLOPE);

  vec3 color = vec3(0.06, 0.07, 0.09);
  if (hit.x > 0.0) {
    vec3 pos = eye + rd * hit.x;
    vec3 n = calcNormal(pos, 0.0015);
    // 素の Lambert + 一定の環境光。ここは絵の仕上げではなく形の確認なので、
    // 下の色は「画面に出る sRGB 値」として直接選んでいる(光の量ではない。
    // 第27章 3 節)。リニア / sRGB の往復を通すのはデモ4 だけ(本文 7 節)
    float diffuse = max(dot(n, LIGHT_DIRECTION), 0.0);
    // 床(y = -0.75)だけ色を変える。step の引数は「床なら 1」になるように選んだ
    vec3 albedo = mix(vec3(0.85, 0.62, 0.38), vec3(0.30, 0.34, 0.40), step(0.74, -pos.y));
    color = albedo * (0.18 + 0.82 * diffuse);
    color *= 1.0 - 0.85 * smoothstep(4.0, 14.0, hit.x); // 遠くを沈めて奥行きを出す
  }

  // 仕切り線は描画バッファの px 単位で引く(第4部の規約)
  float toEdge = abs(gl_FragCoord.x - halfWidth);
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));

  fragColor = vec4(color, 1.0);
}
src/lessons/28-raymarching/03-normal.frag
#version 300 es

// 第28章 デモ3: 法線を数値微分で作る。
// 画面を左右 2 区画に分け、同じシーンを同じカメラから描く。
//   左 = 中心差分で作った法線を、そのまま色にしたもの(n * 0.5 + 0.5)
//   右 = その法線に Lambert を当てたもの
// ポインタ: 左右で方位角、上下で差分の刻み eps(10^-7 〜 10^-1 の対数スケール)。
// 小さすぎると差が float の精度に埋もれ、大きすぎると細部が丸まる(本文 5 節)。

precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "march.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
const int MAX_STEPS = 96;
const float MAX_DIST = 24.0;
const float EPS = 0.0015;
const float EPS_SLOPE = 0.25;
const float FOV = 45.0;
const vec3 TARGET = vec3(0.0, -0.05, 0.0);
const float RADIUS = 3.6;
const float PHI = 0.26;
// 差分の刻みの範囲(10 の指数で指定する)
const float LOG_EPS_MIN = -7.0;
const float LOG_EPS_MAX = -1.0;
// 溶ける幅
const float BLEND_K = 0.30;
// 面から光源へ向かう単位ベクトル(第17章の約束)
const vec3 LIGHT_DIRECTION = normalize(vec3(0.45, 0.80, 0.40));
// ----------------------------------------------------------------------

float map(vec3 p) {
  float d = sdSphere(p - vec3(-0.42, 0.06, 0.0), 0.46);
  d = smin(d, sdBox(p - vec3(0.46, -0.06, 0.0), vec3(0.30)) - 0.05, BLEND_K);
  d = smin(d, sdTorus(p - vec3(0.0, 0.02, 0.0), vec2(0.98, 0.10)), BLEND_K);
  return min(d, sdPlane(p, vec3(0.0, 1.0, 0.0), 0.75));
}

void main() {
  float halfWidth = u_resolution.x * 0.5;
  bool right = gl_FragCoord.x > halfWidth;
  vec2 origin = vec2(right ? halfWidth : 0.0, 0.0);
  float side = min(halfWidth, u_resolution.y);
  vec2 p = ((gl_FragCoord.xy - origin) * 2.0 - vec2(halfWidth, u_resolution.y)) / side;

  vec2 mouse01 = u_mouse / u_resolution;
  float theta = mix(-1.2, 1.2, mouse01.x);
  float normalEps = pow(10.0, mix(LOG_EPS_MIN, LOG_EPS_MAX, mouse01.y));

  vec3 eye = orbitEye(TARGET, theta, PHI, RADIUS);
  mat3 basis = cameraBasis(eye, TARGET);
  vec3 rd = cameraRay(basis, p, 1.0 / tan(radians(FOV) * 0.5));

  vec2 hit = march(eye, rd, MAX_STEPS, MAX_DIST, EPS, EPS_SLOPE);

  vec3 color = vec3(0.06, 0.07, 0.09);
  if (hit.x > 0.0) {
    vec3 pos = eye + rd * hit.x;
    vec3 n = calcNormal(pos, normalEps);
    // どちらの区画も法線を見るための可視化なので、色は「画面に出る sRGB 値」として
    // 直接書いている(光の量ではない。第27章 3 節)。リニア / sRGB の往復を通すのは
    // デモ4 だけ(本文 7 節)。左半分は法線をそのまま色にしているので、往復を通す相手ではない
    if (right) {
      float diffuse = max(dot(n, LIGHT_DIRECTION), 0.0);
      color = vec3(0.78, 0.74, 0.70) * (0.16 + 0.84 * diffuse);
    } else {
      // 向きをそのまま色に。x が赤、y が緑、z が青
      color = n * 0.5 + 0.5;
    }
    color *= 1.0 - 0.7 * smoothstep(3.5, 12.0, hit.x);
  }

  float toEdge = abs(gl_FragCoord.x - halfWidth);
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));

  fragColor = vec4(color, 1.0);
}
src/lessons/28-raymarching/04-scene.frag
#version 300 es

// 第28章 デモ4: 仕上げ。ここまでの部品を全部使った一枚絵。
//   ① 有限繰り返し(clamp した番地)で並べた 16 本の柱
//   ② smin で溶かした球 + 輪と、その台座
//   ③ 中心差分の法線 → Lambert
//   ④ 光源方向へもう 1 本レイを進めるソフトシャドウ(直接光に掛ける)
//   ⑤ 法線方向 5 点のアンビエントオクルージョン(環境光に掛ける)
//   ⑥ 距離フォグ
//   ⑦ ライティングはリニア空間で計算し、出す直前だけ sRGB へ戻す(第27章 2 節)
// ポインタ: 左右で方位角、上下で影のやわらかさ(下がやわらかい = 光源が大きい)。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "color.glsl"
#include "march.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
const int MAX_STEPS = 96;
const float MAX_DIST = 26.0;
const float EPS = 0.0015;
const float EPS_SLOPE = 0.25;
const float FOV = 45.0;
const vec3 TARGET = vec3(0.0, -0.05, 0.0);
const float RADIUS = 5.0;
const float PHI = 0.22;
// 柱の格子。セルの一辺と、番地を止める範囲(-2〜1 の 4 × 4 = 16 本)
const float CELL = 2.20;
const vec2 LIMIT_LO = vec2(-2.0);
const vec2 LIMIT_HI = vec2(1.0);
// 溶ける幅
const float BLEND_K = 0.28;
// 影のレイに許すステップ数と、始点を法線方向へ逃がす量
const int SHADOW_STEPS = 48;
const float SHADOW_MIN_T = 0.03;
const float SHADOW_MAX_T = 9.0;
const float SHADOW_BIAS = 0.012;
// アンビエントオクルージョンの刻みと強さ
const float AO_DELTA = 0.12;
const float AO_STRENGTH = 4.0;
// フォグの濃さ。exp(-t * FOG_DENSITY) が残る割合
const float FOG_DENSITY = 0.12;
// 面から光源へ向かう単位ベクトル(第17章の約束)
const vec3 LIGHT_DIRECTION = normalize(vec3(0.42, 0.72, 0.55));
// 色はすべて sRGB で書き、読んだ直後にリニアへ直す(第27章 3 節)
const vec3 SUN_SRGB = vec3(1.00, 0.94, 0.86);
const float SUN_INTENSITY = 1.15;
const vec3 SKY_LOW_SRGB = vec3(0.06, 0.07, 0.09);
const vec3 SKY_HIGH_SRGB = vec3(0.15, 0.18, 0.24);
const float AMBIENT = 0.34;
const vec3 BODY_SRGB = vec3(0.86, 0.66, 0.42);
const vec3 PILLAR_SRGB = vec3(0.72, 0.74, 0.78);
const vec3 FLOOR_SRGB = vec3(0.40, 0.43, 0.48);
// ----------------------------------------------------------------------

// シーンを 3 つの部品に分けて書く。map はその min を返すだけ
float dFloor(vec3 p) {
  return sdPlane(p, vec3(0.0, 1.0, 0.0), 0.70);
}

// 柱。番地を clamp で止めた有限繰り返し(第24章 6 節の 3D 版)。
// - 0.5 しているのは、セルの中心を半セルずらして原点(中央)を空けるため
float dPillars(vec3 p) {
  vec2 id = clamp(round(p.xz / CELL - 0.5), LIMIT_LO, LIMIT_HI);
  vec2 cell = p.xz - CELL * (id + 0.5);
  return sdBox(vec3(cell.x, p.y + 0.08, cell.y), vec3(0.11, 0.62, 0.11)) - 0.04;
}

// 台座と、その上の球 + 輪。球だけゆっくり上下する
float dBody(vec3 p) {
  vec3 c = vec3(0.0, 0.12 + 0.06 * sin(u_time * 0.6), 0.0);
  float d = sdSphere(p - c, 0.50);
  d = smin(d, sdTorus(p - vec3(0.0, 0.12, 0.0), vec2(0.86, 0.10)), BLEND_K);
  return min(d, sdBox(p - vec3(0.0, -0.66, 0.0), vec3(0.72, 0.05, 0.72)) - 0.03);
}

float map(vec3 p) {
  return min(dFloor(p), min(dPillars(p), dBody(p)));
}

// map が返すのは float 1 つだけなので、「どの部品に当たったか」は残らない。
// 当たった点で部品ごとの距離を測り直し、いちばん近いものの色を使う
vec3 albedoAt(vec3 p) {
  float a = dFloor(p);
  float b = dPillars(p);
  float c = dBody(p);
  if (a < b && a < c) {
    return FLOOR_SRGB;
  }
  return b < c ? PILLAR_SRGB : BODY_SRGB;
}

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  vec2 mouse01 = u_mouse / u_resolution;
  float theta = mix(-1.2, 1.2, mouse01.x);
  float shadowK = mix(4.0, 40.0, mouse01.y); // 光源の大きさの逆数に相当(本文 6 節)

  vec3 eye = orbitEye(TARGET, theta, PHI, RADIUS);
  mat3 basis = cameraBasis(eye, TARGET);
  vec3 rd = cameraRay(basis, p, 1.0 / tan(radians(FOV) * 0.5));

  // 空とフォグの色。sRGB で書いた定数をリニアへ直してから計算に入れる
  vec3 sky = srgbToLinear(mix(SKY_LOW_SRGB, SKY_HIGH_SRGB, smoothstep(-0.1, 0.6, rd.y)));

  vec2 hit = march(eye, rd, MAX_STEPS, MAX_DIST, EPS, EPS_SLOPE);

  vec3 color = sky;
  if (hit.x > 0.0) {
    vec3 pos = eye + rd * hit.x;
    // 法線の刻みを距離に比例させる。遠くの 1 ピクセルは太いので、
    // そこで細かい差分を取っても揺れるだけになる(本文 5 節)
    vec3 n = calcNormal(pos, 0.0006 * (1.0 + hit.x));
    vec3 albedo = srgbToLinear(albedoAt(pos));

    float diffuse = max(dot(n, LIGHT_DIRECTION), 0.0);
    float shadow = softShadow(
        pos + n * SHADOW_BIAS, LIGHT_DIRECTION, SHADOW_MIN_T, SHADOW_MAX_T, SHADOW_STEPS, shadowK);
    float ao = calcAO(pos, n, AO_DELTA, AO_STRENGTH);

    // 影は直接光にだけ掛け、AO は環境光にだけ掛ける(第21章 10 節・第17章 6 節)
    vec3 direct = srgbToLinear(SUN_SRGB) * SUN_INTENSITY * diffuse * shadow;
    vec3 ambient = sky * AMBIENT * ao;
    color = albedo * (direct + ambient);

    // 距離フォグ。奥へ行くほど空の色に置き換わる
    color = mix(sky, color, exp(-hit.x * FOG_DENSITY));
  }

  // 画面へ出す最後の 1 行。ここまでずっとリニア空間で計算してきた(第27章 2 節)
  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/28-raymarching/main.ts
// 第28章: レイマーチング
// 第2部と同じ「1 canvas + 切替ボタン」構成(第10章の main.ts をそのまま踏襲)。
// 4 本のフラグメントシェーダーが共通のチャンク(march.glsl / color.glsl)を
// #include で取り込むので、送る前に文字列置換で解決する。

import { startFullscreenShader } from '../../lib/fullscreen-shader';
import stepsSource from './01-steps.frag?raw';
import repeatSource from './02-repeat.frag?raw';
import normalSource from './03-normal.frag?raw';
import sceneSource from './04-scene.frag?raw';
import colorChunkSource from './color.glsl?raw';
import marchChunkSource from './march.glsl?raw';

// 第23章から続く極小のインクルード。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため。
// color.glsl と march.glsl は互いに依存していないので、順序はどちらでもよい
function resolveIncludes(source: string): string {
  return source
    .replace('#include "color.glsl"', () => colorChunkSource.trim())
    .replace('#include "march.glsl"', () => marchChunkSource.trim());
}

const demos = [
  { id: 'steps', label: 'ステップ数', source: resolveIncludes(stepsSource) },
  { id: 'repeat', label: '繰り返しと破綻', source: resolveIncludes(repeatSource) },
  { id: 'normal', label: '法線と刻み幅', source: resolveIncludes(normalSource) },
  { id: 'scene', label: '影 + AO + フォグ', source: resolveIncludes(sceneSource) },
] as const;

// この章だけ dpr の上限を 1.5 に下げている(既定は 2)。
// 全画面レイマーチングは 1 ピクセルあたり map() を数十回評価するので、
// コストがピクセル数にそのまま比例する。dpr 2 → 1.5 でピクセル数は
// (1.5 / 2)^2 = 0.5625 倍になる。先例は第23章(ディファードの G-buffer)
const MAX_DPR = 1.5;

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
if (!canvas || !controls) {
  throw new Error('デモに必要な要素が見つかりません');
}

let handle = startFullscreenShader(canvas, demos[0].source, { maxDpr: MAX_DPR });

for (const [index, demo] of demos.entries()) {
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = demo.label;
  button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
  button.addEventListener('click', () => {
    // いまのループを止めてから、新しいシェーダーで開始し直す(第6章の stop() の出番)。
    // 古いプログラムの解放は省略している(後始末は第35章)
    handle.stop();
    handle = startFullscreenShader(canvas, demo.source, { maxDpr: MAX_DPR });
    for (const b of controls.querySelectorAll('button')) {
      b.setAttribute('aria-pressed', 'false');
    }
    button.setAttribute('aria-pressed', 'true');
  });
  controls.append(button);
}

three.js との対応

この章の内容は、three.js のどの機能にも対応しません。対応するのは「シェーダーを 1 枚書く」という 1 点だけです。だから対応表は、第3部で使った機能がどこへ消えたかを並べる形になります。

three.jsこの章
BufferGeometryに頂点・法線・UV を詰める無い。形はmap(vec3)という関数だけ
PerspectiveCamera(fov/aspect/near/far)fovはfocal = 1 / tan(fov / 2)、aspectは標準座標の 1 行、near/farは march の開始位置とMAX_DIST
renderer.sortObjectsと深度バッファ不要。レイに沿って手前から進むので、最初のヒットが手前
MeshNormalMaterial(法線を色にする)calcNormalの結果をn * 0.5 + 0.5にする(デモ 3 の左)
DirectionalLight+castShadow+shadowMapsoftShadow— 光源方向へレイを 1 本。深度テクスチャもシャドウカメラも無い
AO は本体には無い。aoMapに焼いたテクスチャを貼るか、three/addonsの SSAO / GTAO パス(スクリーン空間)を使うcalcAO— 距離場を 5 回測るだけ。3D の情報を使うので、画面外の物も遮蔽に効く
Scene.fog(Fog/FogExp2)exp(-t * FOG_DENSITY)の 1 行。tは march がすでに持っている
InstancedMeshで同じ形を並べる座標の折り畳み。個数によらず一定のコストだが、形をセルの中央に 置かないと距離が壊れる(4 節)
renderer.outputColorSpaceが sRGB 変換を入れてくれるlinearToSrgbを出力の最後に自分で 1 行(第27章 2 節)

手元で動かして、壊してみる

この章のコードはsrc/lessons/28-raymarching/にあります。共通部分はmarch.glslにあるので、そこを直すと 4 本すべてに効きます。

まとめ

次章第29章「フィードバック」では、この章に 決定的に無いもの —時間を手に入れます。この章のレイマーチングは、どのフレームもまっさらな状態から計算し直しています。u_timeを変えれば絵は動きますが、それは「時刻 t の絵」を毎回ゼロから作っているだけで、前のフレームの 結果はどこにも残っていません。軌跡を引く、状態を積み上げる、隣のピクセルの結果を見ながら 育っていく模様を作る — そのどれにも前フレームを読む仕組みが要ります。第1章で 「各要素は互いの結果に依存できない」と書いた制約を、第20章の FBO を 2 枚使うping-pongで乗り越えます。