WebGLでキューブマッピング


WebGLでキューブマッピングを試してみたので、解説記事を残しておきます。

サンプルはGithubに置いておきました。
[aadebdeb/Sample\_WebGL\_EnvironmentMapping: Sample of Environment Mapping in WebGL](https://github.com/aadebdeb/Sample_WebGL_EnvironmentMapping)


![cubemap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/18d90aba-a3d7-5ab1-ba40-3e1385d3d7c2.png)


## キューブマップテクスチャの作成

キューブマップはキューブの各面に対応した6つの画像からできています。キューブマッピングをおこなうためには6つの画像を読み込み、その後にキューブマップテクスチャを作成する必要があります。
画像の読み込みは非同期に行われます。そのため、`Promise.all`ですべての画像の読み込みが完了してからキューブマップテクスチャを作成するようにします。テクスチャ作成時には`gl.TEXTURE_2D`ではなく`gl.TEXTURE_CUBE_MAP`を使用し、`gl.texImage2D`でキューブを構成する6つの面に対してそれぞれ画像を割り当てていきます。

```js

// キューブマップテクスチャを作成する関数
function createCubeMapTexture(gl, imageInfos) {
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture); // キューブマップにはgl.TEXTURE_CUBE_MAPを使う

  imageInfos.forEach(imageInfo => { // キューブマップの各面に画像を割り当てる
    gl.texImage2D(imageInfo.target, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageInfo.image);
  });

  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
  return texture;
}

// 非同期にキューブマップの画像を読み込む関数
const createLoadImagePromise = function(imageInfo) {
  return new Promise(function(resolve) {
    const image = new Image();
    image.onload = function() {
      resolve({ image: image, target: imageInfo.target });
    }
    image.src = imageInfo.src;
  });
}

// キューブマップの各画像とtargetの対応関係を表したオブジェクト
const imageInfos = [
  { src: './resources/cubemap/px.png', target: gl.TEXTURE_CUBE_MAP_POSITIVE_X },
  { src: './resources/cubemap/py.png', target: gl.TEXTURE_CUBE_MAP_POSITIVE_Y },
  { src: './resources/cubemap/pz.png', target: gl.TEXTURE_CUBE_MAP_POSITIVE_Z },
  { src: './resources/cubemap/nx.png', target: gl.TEXTURE_CUBE_MAP_NEGATIVE_X },
  { src: './resources/cubemap/ny.png', target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Y },
  { src: './resources/cubemap/nz.png', target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Z },
];

let skyboxTexture;
Promise.all(imageInfos.map(imageInfo => createLoadImagePromise(imageInfo))).then(imageInfos => {
  // すべての画像の読み込みが完了してからキューブマップテクスチャの作成を行う
  skyboxTexture = createCubeMapTexture(gl, imageInfos);
  ...
});
```

## 背景の描画

キューブマップを利用して背景を描画します。軽く調べた感じでは、原点に置かれたカメラを囲むようにボックスを配置し、その頂点位置を利用してキューブマップからサンプリングするのが一般的な方法なようです。しかし、ボックスを利用しなくてもポストエフェクトのようにスクリーン全体埋める平面メッシュを使って背景を描画したほうが効率的な気がしたので、そのように実装しました。イメージとしてはカメラの前に平面を置いて、カメラの動きに合わせて平面を回転させる感じです。カメラの画角に応じて平面の大きさを変えるようにします。

頂点シェーダーの`u_skyboxMatrix`がカメラの回転を表す行列で、`vec3(position * u_targetScale, -1.0)`が回転する前の平面上での位置を表しています。これらを掛け合わせることでカメラの向きを考慮したサンプリング方向を取得できます。フラグメントシェーダーでは、頂点シェーダーで求めたサンプリング方向を使って`texture(u_skyboxTexture, v_dir).rgb`で背景色を取得します。


```glsl:頂点シェーダー
#version 300 es

out vec3 v_dir;

uniform mat4 u_skyboxMatrix;
uniform vec2 u_targetScale;

const vec2[4] POSITIONS = vec2[](
  vec2(-1.0, -1.0),
  vec2(1.0, -1.0),
  vec2(-1.0, 1.0),
  vec2(1.0, 1.0)
);

const int[6] INDICES = int[](
  0, 1, 2,
  3, 2, 1
);

void main(void) {
  vec2 position = POSITIONS[INDICES[gl_VertexID]];
  vec3 dir = (u_skyboxMatrix * vec4(vec3(position * u_targetScale, -1.0), 0.0)).xyz;
  v_dir = normalize(dir);
  gl_Position = vec4(position, 0.0, 1.0);
}
```

```glsl:フラグメントシェーダー
#version 300 es

precision highp float;

in vec3 v_dir;

out vec4 o_color;

uniform samplerCube u_skyboxTexture;

#define HALF_PI 1.57079632679

mat2 rotate(float r) {
  float c = cos(r);
  float s = sin(r);
  return  mat2(c, s, -s, c);
}

vec4 sampleCubemap(samplerCube cubemap, vec3 v) {
  // キューブマップ画像は右手座標系なので、左手座標系に変換する
  // 詳しくはこの記事のコメント欄を参照してください
  v.xz *= rotate(HALF_PI);
  v.x *= -1.0;
  return texture(cubemap, v);
}

void main(void) {
  vec3 skybox = sampleCubemap(u_skyboxTexture, v_dir).rgb;
  o_color = vec4(skybox, 1.0);
}
```

以下は背景を描画するときのJavaScriptのコードになっています。キューブマップをuniformとして渡すときにも`gl.TEXTURE_CUBE_MAP`を使います。また、背景なので深度テストを行わないようにしています。今回はシェーダー側で平面の頂点位置を持っているので、頂点バッファーは使いません。

```js

function setUniformCubeMapTexture(gl, index, texture, location) {
  gl.activeTexture(gl.TEXTURE0 + index);
  gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
  gl.uniform1i(location, index);
}

const skyboxMatrix = Matrix4.lookTo(Vector3.sub(Vector3.zero, cameraPosition), Vector3.up);

gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.useProgram(skyboxProgram);
setUniformCubeMapTexture(gl, 0, skyboxTexture, skyboxUniforms['u_skyboxTexture']);
gl.uniformMatrix4fv(skyboxUniforms['u_skyboxMatrix'], false, skyboxMatrix.elements);
const heightScale = Math.tan(0.5 * CAMERA_VFOV * Math.PI / 180.0);
const widthScale = canvas.width / canvas.height * heightScale;
gl.uniform2fv(skyboxUniforms['u_targetScale'], [widthScale, heightScale]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
```

## オブジェクトの描画

オブジェクトの描画では、拡散反射(diffuse)と鏡面反射(specular)に分けて計算します。入射した光の鏡面反射率を表すフレネル係数を使って拡散反射と鏡面反射の割合を決定します。拡散反射では最大ミップマップレベルでキューブマップをサンプリングします。鏡面反射では視線の反射方向ベクトルでキューブマップをサンプリングします。`roughness`の値に応じてミップマップレベルを設定することで、glossyな反射を疑似的に再現しています。

このあたりのシェーディングについては以下の記事を参考にしたので、詳しくはそちらを参照してください。
[three\.js \+ キューブマップでお手軽IBL \- Qiita](https://qiita.com/kaneta1992/items/df1ae53e352f6813e0cd)

```glsl:フラグメントシェーダー
#version 300 es

precision highp float;

in vec3 v_position;
in vec3 v_normal;

out vec4 o_color;

uniform samplerCube u_skyboxTexture;
uniform float u_maxLodLevel;
uniform vec3 u_cameraPos;
uniform vec3 u_albedo;
uniform float u_roughness;
uniform float u_metallic;
uniform float u_diffIntensity;
uniform float u_specIntensity;

#define HALF_PI 1.57079632679

mat2 rotate(float r) {
  float c = cos(r);
  float s = sin(r);
  return  mat2(c, s, -s, c);
}

vec4 sampleCubemapLod(samplerCube cubemap, vec3 v, float lod) {
  v.xz *= rotate(HALF_PI);
  v.x *= -1.0;
  return textureLod(cubemap, v, lod);
}

vec3 fresnelSchlick(vec3 f90, float cosine) {
  return f90 + (1.0 - f90) * pow(1.0 - cosine, 5.0);
}

void main(void) {
  vec3 normal = normalize(v_normal);
  vec3 viewDir = normalize(u_cameraPos - v_position);
  vec3 reflectDir = reflect(-viewDir, normal);
  float dotNR = clamp(dot(normal, reflectDir), 0.0, 1.0);

  vec3 diffColor = mix(vec3(0.0), u_albedo, 1.0 - u_metallic);
  vec3 specColor = mix(vec3(0.04), u_albedo, u_metallic);

  vec3 skyboxDiff = u_diffIntensity * sampleCubemapLod(u_skyboxTexture, normal, u_maxLodLevel).rgb;
  vec3 skyboxSpec = u_specIntensity * sampleCubemapLod(u_skyboxTexture, reflectDir, log2(u_roughness * pow(2.0, u_maxLodLevel))).rgb;

  vec3 color = skyboxDiff * diffColor + fresnelSchlick(specColor, dotNR) * skyboxSpec;
  o_color = vec4(color, 1.0);
}
```

## 終わりに

WebGLでのキューブマッピングを行う方法について解説しました。キューブマッピングを利用してフレネル係数を考慮したシェーディングを行うとリアリティが一気に高まる感じがします。

~~サンプルをよく見ると、背景の文字列が鏡文字になっています。[このサイト](https://github.com/matheowis/HDRI-to-CubeMap)を使って全天球画像からキューブマップ画像を作成したのですが、おそらく左手座標系か右手座標系かで使用するキューブマップの構成が違うのが原因ではないかと考えています(間違ってたら教えてください...)。気になる場合は、サンプリング時に`texture(u_skyboxTexture, v_dir * vec3(-1.0, 1.0, 1.0))`という感じでxの値を反転させると直ります。~~

## 追記

キューブマップの文字列が反転している問題ですが、@emadurandal さんにコメント欄で原因を教えていただいたのでコードを修正しました。


## 参考

* [wgld\.org \| WebGL: キューブ環境マッピング \|](https://wgld.org/d/webgl/w044.html)
* [three\.js \+ キューブマップでお手軽IBL \- Qiita](https://qiita.com/kaneta1992/items/df1ae53e352f6813e0cd)

---

この記事で使用している画像は[sIBL Archive](http://www.hdrlabs.com/sibl/archive.html)から取得しました。