WebGLでオブジェクトスペースレイマーチング

WebGLでオブジェクトスペースレイマーチングを試してみました。

オブジェクトスペースレイマーチング自体については以下の記事に詳しく書いてあるので省略します。
実装についても参考にさせていただきました。

* [object space raymarching \- primitive: blog](http://i-saint.hatenablog.com/entry/2015/08/24/225254)
* [Unity でオブジェクトスペースのレイマーチをやってみた \- 凹みTips](http://tips.hecomi.com/entry/2016/09/26/014539)

サンプルはGitHubに置いておきました。
[aadebdeb/Sample\_WebGL\_ObjectSpaceRayMarching: Sample of Object Space Ray Marching in WebGL](https://github.com/aadebdeb/Sample_WebGL_ObjectSpaceRayMarching)

サンプルでは以下のように真ん中の大きい球をメッシュで、格子上に配置された小さい球をレイマーチングで描画しています。重なっているときに深度が考慮されて描画されているのがわかると思います。

![movie.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/31c6d601-35c5-c1d4-726f-11cef1edf500.gif)

レイマーチングを行うための頂点シェーダーは以下のようになっています。

```glsl
#version 300 es

// ボックスの頂点位置
const vec3[8] CUBE_POSITIONS = vec3[](
  vec3(-1.0, -1.0,  1.0),
  vec3( 1.0, -1.0,  1.0),
  vec3( 1.0, -1.0, -1.0),
  vec3(-1.0, -1.0, -1.0),
  vec3(-1.0,  1.0,  1.0),
  vec3( 1.0,  1.0,  1.0),
  vec3( 1.0,  1.0, -1.0),
  vec3(-1.0,  1.0, -1.0)
);

// ボックスの各面の法線
const vec3[6] CUBE_NORMALS = vec3[](
  vec3(0.0, 0.0, 1.0),
  vec3(1.0, 0.0, 0.0),
  vec3(0.0, 0.0, -1.0),
  vec3(-1.0, 0.0, 0.0),
  vec3(0.0, 1.0, 0.0),
  vec3(0.0, -1.0, 0.0)
);

// ボックスの頂点の位置を取得するためのインデックス
const int[36] CUBE_INDICES = int[](
  0, 5, 4, 0, 1, 5,
  1, 6, 5, 1, 2, 6,
  2, 7, 6, 2, 3, 7,
  3, 4, 7, 3, 0, 4,
  4, 6, 7, 4, 5, 6,
  3, 1, 0, 3, 2, 1
);

out vec3 v_position;
out vec3 v_normal;

uniform mat4 u_mvpMatrix; // MVP行列
uniform mat4 u_modelMatrix; // モデル行列(スケール成分は含まない)
uniform vec3 u_scale; // スケール成分

void main(void) {
  vec3 position = u_scale * CUBE_POSITIONS[CUBE_INDICES[gl_VertexID]];
  vec3 normal = CUBE_NORMALS[gl_VertexID / 6];
  v_position = (u_modelMatrix * vec4(position, 1.0)).xyz; // ワールド座標系の頂点位置
  v_normal = (u_modelMatrix * vec4(normal, 0.0)).xyz; // ワールド座標系での法線
  gl_Position = u_mvpMatrix * vec4(position, 1.0);
}
```

レイマーチングをするためのメッシュにはボックスを利用します。ボックスのBufferを作るのがめんどくさかったので、今回は頂点シェーダー内で頂点位置や法線を定義しています。ボックスは2つの三角形からなる面6個で構成されるので、2x3x6で`gl.drawArrays(gl.TRIANGLES, 0, 36);`を呼び出すことで描画することができます。
シェーダー内では単純にフラグメントシェーダーにワールド座標系での頂点位置と法線を渡しているだけです。この頂点位置がレイマーチングの開始地点になります。モデル行列にはスケール成分を入れず、ボックスの大きさを調整するためだけに利用しています。


続いて、フラグメントシェーダーを見ていきます。


```glsl
#version 300 es

precision highp float;

in vec3 v_position;
in vec3 v_normal;

out vec4 o_color;

uniform mat4 u_mvpMatrix; // MVP行列
uniform mat4 u_modelMatrix; // モデル行列
uniform mat4 u_invModelMatrix; // モデル行列の逆行列
uniform vec3 u_scale; // スケール成分
uniform vec3 u_cameraPosition; // ワールド座標系でのカメラ位置

struct Ray {
  vec3 origin;
  vec3 dir;
};

// ワールド座標のレイをオブジェクト座標系のレイに変換する関数
Ray convertRayFromWorldToObject(Ray ray) {
  vec3 origin = (u_invModelMatrix * vec4(ray.origin, 1.0)).xyz;
  vec3 dir = normalize((u_invModelMatrix * vec4(ray.dir, 0.0)).xyz);
  return Ray(origin, dir);
}

// レイマーチの開始点と終了点を求める関数
void getRange(Ray ray, inout float tmin, inout float tmax) {
  for (int i = 0; i < 3; i++) {
    float t1 = (u_scale[i] - ray.origin[i]) / ray.dir[i];
    float t2 = (-u_scale[i] - ray.origin[i]) / ray.dir[i];
    tmin = max(tmin, min(t1, t2));
    tmax = min(tmax, max(t1, t2));
  }
}

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

float map(vec3 p) {
  p = mod(p, 10.0) - 5.0;
  return sdSphere(p, 3.0);
}

vec3 getNormal(vec3 p) {
  float e = 0.01;
  return normalize(vec3(
    map(p + vec3(e, 0.0, 0.0)) - map(p - vec3(e, 0.0, 0.0)),
    map(p + vec3(0.0, e, 0.0)) - map(p - vec3(0.0, e, 0.0)),
    map(p + vec3(0.0, 0.0, e)) - map(p - vec3(0.0, 0.0, e))
  ));
}

// レイマーチングを行う関数
// ヒットした場合はtrueを返し、tにはヒット点を入れる
// ヒットしない場合はfalseを返す
bool raymarch(Ray ray, float tmin, float tmax, out float t) {
  t = tmin;
  vec3 p = ray.origin + t * ray.dir;
  for (int i = 0; i < 32; i++) {
    float d = max(0.0, map(p));
    t += d;
    if (t > tmax) {
      break;
    }
    p += d * ray.dir;
    if (d < 0.01) {
      return true;
    }
  }
  return false;
}

// ヒットした場合の色を決める関数
// positionにはワールド座標系での位置、normalにはワールド座標系での法線が入る
vec3 getColor(vec3 position, vec3 normal) {
  return normal * 0.5 + 0.5;
}

void main(void) {
  vec3 dir = normalize(v_position - u_cameraPosition); // ワールド座標系でのレイの方向
  // オブジェクト座標系でレイマーチングをするので、レイをオブジェクト座標系に変換する
  Ray ray = convertRayFromWorldToObject(Ray(u_cameraPosition, dir));
  float tmin = 0.0;
  float tmax = 1e16;
  getRange(ray, tmin, tmax); // ボックス内でレイマーチングを行うための開始点と終了点を求める
  float t;
  if (raymarch(ray, tmin, tmax, t)) { // レイマーチングを行う
    vec3 position = ray.origin + t * ray.dir;
    vec3 color = getColor(
      (u_modelMatrix * vec4(position, 1.0)).xyz,
      // 開始点でオブジェクトにヒットする(t == tmin)ときは球の法線を使う
      t == tmin ? normalize(v_normal) : (u_modelMatrix * vec4(getNormal(position), 0.0)).xyz
    );
    o_color = vec4(color, 1.0);

    // ヒット位置で深度を書き込む
    vec4 clipPos = u_mvpMatrix * vec4(position, 1.0);
    gl_FragDepth = (clipPos.z / clipPos.w) * 0.5 + 0.5;
  } else { // ヒットしない場合はピクセルを破棄する
    discard;
  }
}
```

まず、`main`関数でワールド座標系でのカメラ位置とフラグメントシェーダーから渡ってきた位置をもとにワールド座標系でのレイの方向を求めています。レイマーチングはオブジェクト座標系で行うので、ワールド座標系で定義されたレイを`convertRayFromWorldToObject`関数でオブジェクト座標系のレイに変換します。次に`getRange`関数でレイの開始点と終了点を求めます。ここで求めた開始点と終了点を利用することでボックスの中だけでレイマーチングを行うことができます。
`raymarch`関数で実際にレイマーチングを行います。ここは引数をもとに普通にレイマーチングをするだけなのでレイマーチングのコードを書いたことがあれば理解できると思います。`raymarch`関数でオブジェクトにヒットしない場合は`false`を返すと`discard`が呼ばれてそのピクセルは破棄されます。オブジェクトにヒットした場合は色を決める処理と深度値を書き込む処理を行います。
`getColor`関数はワールド座標系の位置と法線から色を求める関数です。今回は単純に法線でカラーリングしていますがライトの情報を渡せばシェーディングすることもできます。`t == tmin`のとき、つまり開始点ですでにオブジェクトにヒットしている場合は、距離関数から求まる法線を使うと違和感のある描画結果になるので、ボックスの法線を利用するようにしています。
最後にレイマーチングでヒットした位置からビューポート座標系のz座標の値を求めて、深度値として`gl_FragDepth`に書き込んでいます。これによりほかの3Dオブジェクトとの深度を考慮して描画されるようになります。`gl_FragDepth`の利用方法については[私が以前書いた記事](https://qiita.com/aa_debdeb/items/3306ecee493603ac0dd0)を参考にしてください。

---

WebGLでオブジェクトスペースレイマーチングを試してみました。今回は距離関数を単純な球の繰り返しにしていますが、フラグメントシェーダーの`map`関数を書き換えることで任意の距離関数を使うことができます。また、`getColor`関数を変更することでシェーディングの方法も自由に変更することができます。