12. 法線マッピング

ゲームのレンガの壁は、大抵たった2つの三角形です。
其れなのに光が横切ると、全てのレンガの縁に小さな陰が出来て、目地はレンガより奥にある様に見えます。
其の細部は形状としては存在しません。
色ではなく法線を保存したテクスチャから来ています。
此の章では、そんな法線マップをCで高さマップから生成し、其れで平らな壁を照らし、シェーダーの中で高さから直接法線を計算し、最後にレンガを球に貼ります。
球には接線空間とTBN行列が必要になります。
第11章のテクスチャと、第9章・第10章のライティングの上に作ります。

12.1 考え方:形状無しで細部を

此れ迄のライティングは全て、表面の1つの事だけに依存していました。
其の法線𝐧です。
ランバートの拡散反射はmax(𝐧⋅𝐥,0)(線型代数第4章)、Blinn-Phongの鏡面反射はmax(𝐧⋅𝐡,0)kです。
ライティングは表面が実際に何処にあるかは聞かず、どちらを向いているかだけを聞きます。

そこで此の技です。
表面は平らなままにして、ピクセル毎に別の法線をライティングに渡します。
表面が凸凹だったとしたら持っていた筈の法線です。

左:本物の凸凹の形状と其の法線。右:同じ法線を保存した平らな面

どちらの面もライティングの式に同じ法線を渡すので、同じ様に照らされます。
平らな方は、何千もの三角形ではなく2つの三角形で済みます。
法線マッピングに出来ない事もあります。
物体の輪郭は変えられません(法線マップを貼った球も、縁は完全に丸いままです)。
凸凹同士が本物の影を落とし合う事もありません。
でも表面の内側の光と陰については非常に説得力があり、2000年代半ば以降の殆ど全ての3Dゲームが、殆ど全ての表面で使っています。

12.2 高さマップから法線へ

凸凹を表す一番簡単な方法は高さマップです。
黒が低く(0)、白が高い(1)灰色のテクスチャです。
此処から全てのテクセルの法線を計算出来ます。

式

テクスチャを地形だと思って下さい。
テクセル(x,y)の表面の点は(x,y,sh(x,y))です。
hが高さマップで、sは強さ、つまり高さ1が何テクセル分の深さかです。
法線は次の通りです:

𝐧=normalize(−s∂h∂x,−s∂h∂y,1)

傾きは中心差分で測ります。
右の隣の高さから左の隣の高さを引き、其の間の2テクセルで割ります:

∂h∂x≈h(x+1,y)−h(x−1,y)2,∂h∂y≈h(x,y+1)−h(x,y−1)2

∂h∂xは「hのxについての偏微分」と読み、単に「xだけを動かした時にhがどれだけ速く変わるか」という意味です。

仕組み

地形の上を1テクセル右に歩くと、xは1増え、高さはs∂h∂x増えます。
従って表面は𝐭x=(1,0,s∂h∂x)の方向に進みます。
1テクセル上に歩くと𝐭y=(0,1,s∂h∂y)です。
どちらの矢印も表面の中にあり、外積(線型代数第5章)で両方に垂直なベクトルが得られます。
a=s∂h∂x、b=s∂h∂yと書くと:

𝐭x×𝐭y=[10a]×[01b]=[0⋅b−a⋅1a⋅0−1⋅b1⋅1−0⋅0]=[−a−b1]

此れが正規化する前の式です。
横から見ると簡単に確かめられます。
地面が右に上がっている所では、法線は左に傾きます。

高さの断面を横から見た図:接線は傾きに沿い、法線は其れに垂直で反対側に傾く

平らな所では両方の傾きが0なので、𝐧=(0,0,1)、表面から真っ直ぐ外向きです。

計算例

レンガの左の縁の、右に向かって高さが上がっているテクセルを考えます。
左の隣はh=0.3、右の隣はh=0.7、上と下の隣は同じ高さです。
強さs=3なら:

∂h∂x≈0.7−0.32=0.2,a=3⋅0.2=0.6,b=0
‖(−0.6,0,1)‖=0.36+1=1.36≈1.1662,𝐧≈(−0.61.1662,0,11.1662)=(−0.5145,0,0.8575)

図と同じ様に、法線は上り坂とは反対の左に傾いています。

法線をテクスチャに保存する

単位法線の成分は−1から1の間ですが、GL_RGBA8のテクスチャは0から1を保存します。
其の為、書く時に詰め込み、読む時に広げます:

色=𝐧+12=0.5𝐧+0.5,𝐧=2⋅色−1

例の法線は(0.2427,0.5,0.9287)、バイトにすると(255を掛けて四捨五入)(62,128,237)になります。
平らな法線(0,0,1)は(0.5,0.5,1)、バイトでは(128,128,255)で、明るい青紫色です。
法線マップの殆どの部分は大体真っ直ぐ外を向いているので、法線マップはいつも青っぽく見えます。
戻すと正確な0ではなく128/255⋅2−1≈0.0039になります(8ビットでは127.5を保存出来ません)。
読んだ後に必ずnormalizeする理由の1つです。

12.3 Cでマップを作る

第11章の上に、レンガのテクスチャをmain.cに追加しましょう。
先ず、第11章のcreateTestTextureがループの後にしていた事をする補助関数です。
テクスチャ毎に同じ事を書かずに済みます:

// ピクセルデータからテクスチャを作る(TEX_SIZE×TEX_SIZE、ミップマップ付き、GL_REPEAT)
GLuint uploadTexture(GLint internalFormat, GLenum format, const unsigned char *pixels) {
  GLuint texture;
  glGenTextures(1, &texture);
  glBindTexture(GL_TEXTURE_2D, texture);
  glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, TEX_SIZE, TEX_SIZE, 0,
               format, GL_UNSIGNED_BYTE, pixels);
  glGenerateMipmap(GL_TEXTURE_2D);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
  return texture;
}

高さマップ

レンガは64 × 32テクセルで、1段置きに半分ずらします。
テクセル毎にレンガの一番近い縁迄の距離を求め、2テクセル以内なら目地(高さ0)、其処から4テクセルかけてレンガの上面(高さ1)迄滑らかに上がります。
少しの値ノイズ(第8章と同じ考え方をCで書いた物)で、レンガの表面を少しザラザラにします:

// GLSLのsmoothstepと同じ(0〜1の滑らかな段差)
float smoothstepf(float edge0, float edge1, float x) {
  float t = (x - edge0) / (edge1 - edge0);
  if (t < 0.0f) t = 0.0f;
  if (t > 1.0f) t = 1.0f;
  return t * t * (3.0f - 2.0f * t);
}

// 整数の座標から0〜1の疑似乱数を作るハッシュ(第8章と同じ考え方)
float hash2(int x, int y) {
  unsigned int h = (unsigned int)x * 374761393u + (unsigned int)y * 668265263u;
  h = (h ^ (h >> 13)) * 1274126177u;
  return (float)((h ^ (h >> 16)) & 0xffffu) / 65535.0f;
}

// cellテクセル毎の値ノイズ(TEX_SIZEで繰り返すので、テクスチャの継ぎ目が見えない)
float valueNoise(int x, int y, int cell) {
  int n = TEX_SIZE / cell;
  int cx = x / cell, cy = y / cell;
  float fx = smoothstepf(0.0f, 1.0f, (float)(x % cell) / cell);
  float fy = smoothstepf(0.0f, 1.0f, (float)(y % cell) / cell);
  float a = hash2(cx % n, cy % n);
  float b = hash2((cx + 1) % n, cy % n);
  float c = hash2(cx % n, (cy + 1) % n);
  float d = hash2((cx + 1) % n, (cy + 1) % n);
  return (a + (b - a) * fx) * (1.0f - fy) + (c + (d - c) * fx) * fy;
}

// レンガの高さマップ(0〜1)を作る:レンガは高く、目地は低く、レンガの縁は斜め
float *createBrickHeights(void) {
  float *heights = (float *)malloc(TEX_SIZE * TEX_SIZE * sizeof(float));
  if (!heights) {
    puts("高さマップのメモリ確保に失敗しました");
    return NULL;
  }

  for (int y = 0; y < TEX_SIZE; y++) {
    for (int x = 0; x < TEX_SIZE; x++) {
      // レンガは64×32テクセル、1段毎に半分ずらす
      int row = y / 32;
      int bx = (x + (row % 2) * 32) % 64;
      int by = y % 32;

      // レンガの一番近い縁までの距離(テクセル)
      float ex = fminf(bx + 0.5f, 64.0f - (bx + 0.5f));
      float ey = fminf(by + 0.5f, 32.0f - (by + 0.5f));
      float e = fminf(ex, ey);

      // 縁から2テクセルは目地(高さ0)、其処から4テクセルかけて斜めに上がる
      float h = smoothstepf(2.0f, 6.0f, e);

      // レンガの表面の細かい凸凹
      float rough = 0.6f * valueNoise(x, y, 8) + 0.4f * valueNoise(x, y, 4);
      h *= 0.8f + 0.2f * rough;

      heights[y * TEX_SIZE + x] = h;
    }
  }
  return heights;
}

valueNoiseの% nでノイズは256テクセル毎に回り込み、256はレンガの大きさの倍数でもあるので、テクスチャは目に見える継ぎ目無く繰り返します。
GL_REPEATを使うので此れは大事です。

1つの高さマップから2つのテクスチャ

高さ其の物は1チャンネルのテクスチャとして、其処から計算した法線(12.2節)はRGBAのテクスチャとしてアップロードします:

// 高さマップをそのまま1チャンネルのテクスチャにする(GL_R8)
GLuint createHeightTexture(const float *heights) {
  if (!heights) return 0;
  unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE);
  if (!pixels) return 0;
  for (int i = 0; i < TEX_SIZE * TEX_SIZE; i++) {
    pixels[i] = (unsigned char)(heights[i] * 255.0f + 0.5f);
  }
  GLuint texture = uploadTexture(GL_R8, GL_RED, pixels);
  free(pixels);
  return texture;
}

// 高さマップから法線マップを作る(中心差分、OpenGLの「緑が上」の向き)
GLuint createNormalTexture(const float *heights, float strength) {
  if (!heights) return 0;
  unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE * 4);
  if (!pixels) return 0;

  for (int y = 0; y < TEX_SIZE; y++) {
    for (int x = 0; x < TEX_SIZE; x++) {
      // 隣のテクセル(端では反対側に回り込む、GL_REPEATと同じ)
      int xl = (x + TEX_SIZE - 1) % TEX_SIZE, xr = (x + 1) % TEX_SIZE;
      int yd = (y + TEX_SIZE - 1) % TEX_SIZE, yu = (y + 1) % TEX_SIZE;

      // 1テクセル進んだ時の高さの変化
      float dhdx = (heights[y * TEX_SIZE + xr] - heights[y * TEX_SIZE + xl]) * 0.5f;
      float dhdy = (heights[yu * TEX_SIZE + x] - heights[yd * TEX_SIZE + x]) * 0.5f;

      // 法線 = (-s・dh/dx, -s・dh/dy, 1) を正規化
      float nx = -strength * dhdx;
      float ny = -strength * dhdy;
      float nz = 1.0f;
      float len = sqrtf(nx * nx + ny * ny + nz * nz);
      nx /= len;
      ny /= len;
      nz /= len;

      // -1〜1を0〜255に詰める
      unsigned char *p = &pixels[(y * TEX_SIZE + x) * 4];
      p[0] = (unsigned char)((nx * 0.5f + 0.5f) * 255.0f + 0.5f);
      p[1] = (unsigned char)((ny * 0.5f + 0.5f) * 255.0f + 0.5f);
      p[2] = (unsigned char)((nz * 0.5f + 0.5f) * 255.0f + 0.5f);
      p[3] = 255;
    }
  }

  GLuint texture = uploadTexture(GL_RGBA8, GL_RGBA, pixels);
  free(pixels);
  return texture;
}

GL_REDのGL_R8は1テクセル1バイトで保存します。
シェーダーでサンプリングすると(r,0,0,1)が返るので、高さは.rで読みます。
各行は256バイトで4の倍数なので、第11章のアンパックアラインメントも問題ありません。

3つのテクスチャをバインドする

main()のテスト用テクスチャの後で:

  // レンガの法線マップと高さマップを作る(第12章)
  float *heights = createBrickHeights();
  GLuint normalMap = createNormalTexture(heights, 3.0f);
  GLuint heightMap = createHeightTexture(heights);
  free(heights);

強さ3は、高さの差1が3テクセル分の深さという意味なので、4テクセルの斜面はかなり急です。
レンダリングループでは、ユニット0のテスト用テクスチャの後で、新しい2つのテクスチャをユニット1と2に入れます:

    // 法線マップはユニット1、高さマップはユニット2
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, normalMap);
    glUniform1i(glGetUniformLocation(shaderProgram, "uNormalMap"), 1);
    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, heightMap);
    glUniform1i(glGetUniformLocation(shaderProgram, "uHeightMap"), 2);

此処に1つ罠があります。
アクティブなテクスチャユニットはグローバルな状態で、此のコードの後は誰かが変える迄ユニット2のままです。
次のフレームの最初で、第11章のコードがFキーとWキーの為にglBindTexture(GL_TEXTURE_2D, texture)を呼ぶと、テスト用テクスチャがユニット2に入ってしまい、setFilterも其処で変更してしまいます。
其の為、先にユニット0に戻します:

    // Fキーでフィルタリング、Wキーでラップモードを切り替える
    // (アクティブなユニットは前のフレームの最後のままなので、ユニット0に戻す)
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, texture);

最後に、他の後片付けの隣にglDeleteTextures(1, &normalMap);とglDeleteTextures(1, &heightMap);を追加します。

マップを確かめる為に、次のシェーダーで左に高さマップ、右に法線マップの色をそのまま表示します:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uNormalMap; // ユニット1
uniform sampler2D uHeightMap; // ユニット2

void main() {
  // 画面の左半分と右半分の其々に、縦横比を直したUVを作る
  float halfWidth = uResolution.x * 0.5;
  vec2 local = vec2(mod(gl_FragCoord.x, halfWidth), gl_FragCoord.y);
  vec2 uv = (local - vec2(halfWidth, uResolution.y) * 0.5) / uResolution.y + 0.5;

  float height = texture(uHeightMap, uv).r; // GL_R8は赤チャンネルだけ
  vec3 normalColor = texture(uNormalMap, uv).rgb;

  // 左半分:高さマップ、右半分:法線マップ
  vec3 color = gl_FragCoord.x < halfWidth ? vec3(height) : normalColor;

  out_color = vec4(color, 1.0);
}

左:灰色のレンガの高さマップ。右:殆ど青で、斜面に色が付いた法線マップ

(変数名をhalfWidthにしたのは、halfがGLSLの予約語だからです。)
法線マップは次の様に読みます。
平らな所は(0.5,0.5,1)の青、レンガの右の縁はピンク寄り(赤が多く、法線が+xに傾く)、左の縁は濃い青(赤が少なく、−xに傾く)、上の縁は水色(緑が多く、上に傾く)、下の縁は紫(緑が少なく、下に傾く)です。

12.4 照らされた壁

では壁を照らしましょう。
光はマウスの位置の、壁から0.5手前にあり、目は+zから真っ直ぐ壁を見ています:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform vec2 uMouse;
uniform sampler2D uNormalMap;
uniform sampler2D uHeightMap;

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec2 uv = p * 0.5 + 0.5;

  // 壁の上の点と、マウスの位置にある光(壁から0.5手前)
  vec3 pos = vec3(p, 0.0);
  vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y;
  vec3 lightPos = vec3(mouse, 0.5);
  vec3 L = normalize(lightPos - pos);  // 光への方向
  vec3 V = vec3(0.0, 0.0, 1.0);        // 目への方向

  // 法線:今は只の平らな壁
  vec3 N = vec3(0.0, 0.0, 1.0);

  // 色:低い所(目地)は灰色、高い所(レンガ)は赤茶色
  float h = texture(uHeightMap, uv).r;
  vec3 albedo = mix(vec3(0.3, 0.28, 0.25), vec3(0.5, 0.2, 0.12), smoothstep(0.05, 0.4, h));

  // Blinn-Phong(第9章)
  float diffuse = max(dot(N, L), 0.0);
  vec3 H = normalize(L + V);
  float specular = pow(max(dot(N, H), 0.0), 32.0) * 0.25;
  vec3 color = albedo * (0.05 + diffuse) + vec3(specular);

  // ガンマ補正(第10章)
  color = pow(color, vec3(1.0 / 2.2));
  out_color = vec4(color, 1.0);
}

ライティングは第9章のBlinn-Phongモデルです。
アンビエント0.05、拡散反射max(𝐍⋅𝐋,0)、そしてハーフベクトル𝐇=normalize(𝐋+𝐕)と光沢度32の鏡面反射です。
色は線型の値で、第10章で説明した様に最後にガンマ補正します。
此処では高さマップは色を決めるだけです。

左上から照らされた平らなレンガの壁:レンガは描かれているだけ

正しく照らされていますが、明らかにレンガの絵です。
光は滑らかな明るい点を作るだけです。
では平らな法線を次の1行に置き換えます:

  // 法線:法線マップから読み、0〜1を-1〜1に戻す
  vec3 N = normalize(texture(uNormalMap, uv).rgb * 2.0 - 1.0);

法線マップを使った同じ壁:左上の斜面は光を受け、右下の斜面は暗い

レンガが壁から浮き出ました。
光の方(左上)を向いた斜面は明るく、反対を向いた斜面は暗く、各レンガの表面は少しザラザラで、ハイライトは凸凹で崩れます。
マウスを動かすと光と陰も一緒に動きます。
描いただけのテクスチャには出来ない事です。

何故法線マップのベクトルをそのまま使えたのでしょうか?
法線マップは、テクスチャ自身の座標系で法線を保存しているからです。
其処では+xがuの増える方向、+yがvの増える方向、+zが表面から外向きです。
此の壁では其れが偶然画面のx、y、zと丁度同じなので、変換が要りません。
球では其れが成り立たなくなるので、12.6節で扱います。

12.5 シェーダーで高さマップから法線を計算する

法線はCで焼き込みましたが、シェーダーでも計算出来ます。
Cの法線マップと並べて、2つの方法を見てみましょう:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform vec2 uMouse;
uniform sampler2D uNormalMap;
uniform sampler2D uHeightMap;

// 高さマップから法線を計算する(Cのコードと同じ中心差分)
vec3 normalFromHeight(sampler2D heightMap, vec2 uv, float strength) {
  vec2 texel = 1.0 / vec2(textureSize(heightMap, 0)); // 1テクセルのUVでの大きさ
  float hL = texture(heightMap, uv - vec2(texel.x, 0.0)).r; // 左
  float hR = texture(heightMap, uv + vec2(texel.x, 0.0)).r; // 右
  float hD = texture(heightMap, uv - vec2(0.0, texel.y)).r; // 下
  float hU = texture(heightMap, uv + vec2(0.0, texel.y)).r; // 上
  vec2 slope = vec2(hR - hL, hU - hD) * 0.5; // 1テクセル当たりの高さの変化
  return normalize(vec3(-strength * slope, 1.0));
}

// 画面空間の微分(dFdx・dFdy)で法線を計算する
vec3 normalFromDerivatives(float h, vec2 uv, float strength) {
  vec2 texelsPerPixel = vec2(dFdx(uv.x), dFdy(uv.y)) * vec2(textureSize(uHeightMap, 0));
  vec2 slope = vec2(dFdx(h), dFdy(h)) / texelsPerPixel; // 1テクセル当たりの高さの変化
  return normalize(vec3(-strength * slope, 1.0));
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec2 uv = p * 0.5 + 0.5;

  vec3 pos = vec3(p, 0.0);
  vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y;
  vec3 L = normalize(vec3(mouse, 0.5) - pos);
  vec3 V = vec3(0.0, 0.0, 1.0);

  float h = texture(uHeightMap, uv).r;

  // 3つの方法を比べる:左 = C で作った法線マップ、中 = 中心差分、右 = dFdx・dFdy
  vec3 fromMap = normalize(texture(uNormalMap, uv).rgb * 2.0 - 1.0);
  vec3 fromHeight = normalFromHeight(uHeightMap, uv, 3.0);
  vec3 fromDerivatives = normalFromDerivatives(h, uv, 3.0);
  float third = gl_FragCoord.x / uResolution.x * 3.0;
  vec3 N = third < 1.0 ? fromMap : (third < 2.0 ? fromHeight : fromDerivatives);

  vec3 albedo = mix(vec3(0.3, 0.28, 0.25), vec3(0.5, 0.2, 0.12), smoothstep(0.05, 0.4, h));
  float diffuse = max(dot(N, L), 0.0);
  vec3 H = normalize(L + V);
  float specular = pow(max(dot(N, H), 0.0), 32.0) * 0.25;
  vec3 color = albedo * (0.05 + diffuse) + vec3(specular);
  color = pow(color, vec3(1.0 / 2.2));

  if (abs(fract(third + 0.5) - 0.5) * uResolution.x / 3.0 < 1.0 && third > 0.5) color = vec3(0.0);
  out_color = vec4(color, 1.0);
}

3つに分けた壁:法線マップ、中心差分、画面空間の微分から求めた法線

中心差分(normalFromHeight、真ん中)は、Cのコードをシェーダーに移した物です。
1テクセル左、右、下、上で4回余分にサンプリングします。
見た目は左の焼き込んだ法線マップと同じです。
1回ではなく4回のサンプリングが必要ですが、強さを実行中に変えられ、保存するのは1チャンネルの高さマップだけで済みます。

画面空間の微分(normalFromDerivatives、右)は、第11章のdFdxとdFdyを使います。
dFdx(h)は、此のピクセルから右の隣のピクセル迄に高さがどれだけ変わるかです。
1ピクセル進むと何テクセル進むか(dFdx(uv.x)にテクスチャの大きさを掛けた物)で割ると、「1テクセル当たりの高さの変化」になり、後は12.2節の式です。
余分なサンプリングが全く要りませんが、良く見て下さい:

拡大図:法線マップでは斜面が滑らかで、微分版では2ピクセル幅の段が見える

微分版には階段状の段があり、表面もザラザラです。
GPUは微分を2 × 2ピクセルのブロック毎に1回計算するので(第11章)、ブロックの4つのピクセルが1つの傾きを共有するからです。
小さくて安い細部(デカール、プロシージャルな凸凹)には十分で、正に其の理由でシェーダーアートで人気があります。
又、此の簡単な版が動くのは、壁が画面と揃っていて、uがxに、vがyに沿っているからに過ぎません。
一般の表面では、dFdx(position)も使うMorten Mikkelsenの「微分によるバンプマッピング」の方法が必要です。

12.6 接線空間とTBN行列

法線マップは法線を接線空間で保存しています。
表面の各点に貼り付いた小さな座標系です:

  • 𝐓、接線(タンジェント):表面の上でuが増える方向。
  • 𝐁、従接線(バイタンジェント):vが増える方向。
  • 𝐍、表面其の物の法線(マップ無しの、形状の法線)。

UV格子の付いた曲面。或る点で、接線Tはuに、従接線Bはvに沿い、法線Nは外を向く

法線マップの値(0,0,1)は「表面の法線𝐍其の物」、(1,0,0)なら「寝かせて𝐓の方向を向く」という意味です。
此れが法線マップを使い回せる理由です。
同じレンガのテクスチャが壁にも床にも樽にも球にも使えます。
表面に対して法線がどれだけ傾くかしか言っていないからです。

式

接線空間の法線𝐧tをライティングで使うには、𝐋と𝐕がある空間(此処ではプレイグラウンドのビュー空間)に変換します。
其れは正に線型代数第11章の基底変換です。
古い座標で書いた新しい軸を、列として行列に入れます:

𝐓𝐁𝐍=[𝐓𝐁𝐍],𝐧=𝐓𝐁𝐍𝐧t=nt,x𝐓+nt,y𝐁+nt,z𝐍

GLSLではmat3(T, B, N)(3つのvec3を引数にしたmat3コンストラクタは、其れらを列として使います)とTBN * nTangentです。

仕組み

「𝐓に沿ってnt,x、𝐁に沿ってnt,y、𝐍に沿ってnt,z歩く」。
行列とベクトルの積は、其の列の正にそういう線型結合です。
平らな壁では𝐓=(1,0,0)、𝐁=(0,1,0)、𝐍=(0,0,1)で、TBN行列は単位行列です。
だから12.4節は此れ無しで動きました。

計算例です。
表面の法線が𝐍=(0.6,0,0.8)、接線が𝐓=(0.8,0,−0.6)、従接線が𝐁=(0,1,0)の、球の上の点を考えます。
此れらは正規直交です。
どれも長さ1(0.36+0.64=1)で、𝐍⋅𝐓=0.48−0.48=0です。
法線マップが+uに傾いた𝐧t=(0.6,0,0.8)を返したとすると:

𝐧=0.6[0.80−0.6]+0[010]+0.8[0.600.8]=[0.48+0.480−0.36+0.64]=[0.9600.28]

0.962+0.282=0.9216+0.0784=1なので、まだ単位ベクトルです。
表面其の物よりも更に右に傾いています。

𝐓、𝐁、𝐍は正規直交なので、逆行列は転置です(線型代数第10章):𝐓𝐁𝐍−1=𝐓𝐁𝐍⊤。
多くのエンジンは其れを使って逆向きに変換します。
頂点シェーダーで𝐋と𝐕を接線空間に入れて(頂点毎に1回なので安い)、フラグメントシェーダーでは法線マップの値でそのままライティングします。
どちらでも結果は同じです。

12.7 法線マップを貼った球

次はレンガを球に貼ります。
第9章と同じ「球のインポスター」の考え方を使います。
円を描き、其の中で球の点を計算します。
原点にある半径1の球なら、単位円の中の(x,y)のピクセルにはz=1−x2−y2の点(x2+y2+z2=1から)が見え、単位球では位置が其のまま法線です。

球のUV

地球儀の経度と緯度の様に、全ての点にuとvが必要です:

u=atan2(Nx,Nz)2π+0.5,v=arcsin(Ny)π+0.5

  • 引数が2つのatan(y, x)は、点(x,y)の角度を−πからπで、正しい象限で返します。此処ではNxとNzを渡すので、角度は縦軸の周りを回ります。正面(+z)で0、+xに向かって増えます。2πで割って0.5を足すと、球の周りで0〜1になります。
  • asin(x)は逆正弦arcsinで、−π2からπ2を返します。此れが緯度で、赤道で0、極で±π2、vは下の0から上の1迄です。

周りは2π、上から下はπしかないので、赤道でレンガが歪まない様に、テクスチャを周りに4回、極から極に2回繰り返します。
uに少しuTimeを足すと、球がゆっくり回ります。

球の接線

経度をφ、緯度をθとすると、点は𝐩=(cosθsinφ,sinθ,cosθcosφ)です。
接線はuが増える方向、つまりφが増える方向です:

∂𝐩∂φ=(cosθcosφ,0,−cosθsinφ)=cosθ(cosφ,0,−sinφ)

Nz=cosθcosφ、Nx=cosθsinφなので、此れは単に𝐓=normalize(Nz,0,−Nx)、球の周りを回る水平な方向です。
従接線は𝐁=𝐍×𝐓です。
確かめてみると、此の外積はvが増える方向∂𝐩∂θと等しくなるので、座標系は右手系で、𝐁はちゃんと球の上に向かいます。
丁度極ではNx=Nz=0で接線の方向が決まりません(極では全ての方向が「周り」です)。
其の為、コードは其処では(1,0,0)を使います。

シェーダー

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse;
uniform sampler2D uNormalMap;
uniform sampler2D uHeightMap;

const float PI = 3.14159265;

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  // 半径0.85の球(第9章の球と同じ考え方:zは x² + y² + z² = 1 から)
  vec2 q = p / 0.85;
  float r2 = dot(q, q);
  vec3 N = vec3(q, sqrt(max(1.0 - r2, 0.0))); // 半径1の球では位置 = 法線
  vec3 pos = vec3(p, N.z * 0.85);

  // 球面のUV:経度がu、緯度がv(uを時間でずらして自転させる)
  vec2 uv;
  uv.x = atan(N.x, N.z) / (2.0 * PI) + 0.5 + uTime * 0.03;
  uv.y = asin(clamp(N.y, -1.0, 1.0)) / PI + 0.5;
  uv *= vec2(4.0, 2.0); // 横に4回、縦に2回繰り返すとレンガが歪まない

  // 接線空間の基底:Tはuが増える方向、Bはvが増える方向
  vec3 T = vec3(N.z, 0.0, -N.x);
  T = length(T) > 1e-4 ? normalize(T) : vec3(1.0, 0.0, 0.0); // 極ではTが決まらない
  vec3 B = cross(N, T);
  mat3 TBN = mat3(T, B, N); // 列がT、B、N

  // 法線マップの法線(接線空間)をビュー空間に変換する
  vec3 nTangent = texture(uNormalMap, uv).rgb * 2.0 - 1.0;
  vec3 n = normalize(TBN * nTangent);

  // 光はマウスの位置、球の手前1.5
  vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y;
  vec3 L = normalize(vec3(mouse, 1.5) - pos);
  vec3 V = vec3(0.0, 0.0, 1.0);

  float h = texture(uHeightMap, uv).r;
  vec3 albedo = mix(vec3(0.3, 0.28, 0.25), vec3(0.5, 0.2, 0.12), smoothstep(0.05, 0.4, h));

  // Blinn-Phong
  float diffuse = max(dot(n, L), 0.0);
  vec3 H = normalize(L + V);
  float specular = pow(max(dot(n, H), 0.0), 32.0) * 0.25;
  vec3 color = albedo * (0.05 + diffuse) + vec3(specular);

  // 球の外は背景
  if (r2 > 1.0) color = vec3(0.02, 0.025, 0.04);

  color = pow(color, vec3(1.0 / 2.2));
  out_color = vec4(color, 1.0);
}

左上から照らされたレンガの球。全てのレンガに明るい斜面と暗い斜面がある

cross(a, b)は線型代数第5章の外積𝐚×𝐛です。
clamp(N.y, -1.0, 1.0)は、丸め誤差で−1〜1を少しだけはみ出した値からasinを守ります。
はみ出すとNaNを返してしまうからです。
texture()は球の外も含めて全てのピクセルで呼び、背景を選ぶのは其の後である事に注意して下さい。
第11章の一様な制御フローのルールです。
球の縁の近くではUVがピクセル毎にとても速く変わるので、ミップマップは其処で小さいレベルを選び、レンガが柔らかくなります。
此れは正しい動作です。

法線マップが何を足しているか見る為に、左半分では球自身の法線だけを使い(if (p.x < 0.0) n = N;)、光を上に置いた同じ球です:

2つに分けた球:左は滑らかでプラスチックの様、右は本物の凹凸があるレンガ

左半分は色を塗ったボール、右半分はレンガで組んだ様に見えます。
どちらも輪郭は全く同じです。
法線マッピングが唯一変えられない物です。
光を動かすとこうなります:

12.8 良くある間違い

法線マッピングには典型的なバグが幾つかあります。
殆どは明らかに壊れているのではなく「何か少し変」なライティングになるので、見付けるのが難しいです。

緑チャンネル。
法線マップのyには2つの流儀があります。
OpenGL流(Blender、Unity、glTFも)では緑は「+vに傾く」を意味し、今回のCのコードも其れを作ります。
DirectX流(Unreal、3ds Maxも)ではyを反転して保存します。
DirectXの法線マップをOpenGL流のシェーダーで使うと、全ての凸凹が縦方向に逆から照らされている様に見えます。
次の画像の右半分はN.yを反転していて、光は上にあります:

2つに分けた壁:左は正しく、緑チャンネルを反転した右では、レンガが上下から押し込まれた様に見える

左では上の斜面が光を受けています。
右では下の斜面が光を受けるので、レンガは下から照らされている様に見えるのに、横の斜面は正しいままで、混乱する見た目になります。
直すには、戻した後にn.y = -n.yとするか、画像の緑チャンネルを一度反転させます。

戻し忘れ。
色をそのまま法線として使う(* 2.0 - 1.0無し)と、法線がいつも(+x,+y)に傾き、表面全体が右上から照らされている様に見えます。

正規化しない。
バイリニアフィルタリングとミップマップは隣の法線を平均し、単位ベクトルの平均は1より短くなります(反対向きの2つの斜面の平均は殆ど0です)。
戻した後に必ずnormalizeし、TBN行列を掛けた後にももう一度正規化して下さい。

歪んだTBN。
本物のメッシュでは𝐓と𝐍は頂点から来て三角形の上で補間されるので、フラグメントシェーダーではもう長さ1でも完全に垂直でもありません。
𝐍を正規化し、𝐓から𝐍方向の成分を取り除き(内積による射影、線型代数第4章)、其れから𝐁を作ります:

𝐓′=normalize(𝐓−(𝐍⋅𝐓)𝐍),𝐁=𝐍×𝐓′

此れをグラム・シュミットの直交化と呼び、殆ど全ての法線マッピングのフラグメントシェーダーの最初の行です。

sRGB。
法線マップと高さマップは色ではなくデータです。
GL_SRGB8_ALPHA8として読み込むと、サンプラーが元々無かったガンマを「戻して」しまい、全ての法線が曲がります。
色のテクスチャはsRGB、データのテクスチャは線型です(第11章の一覧)。

空間が混ざっている。
内積を取る前に、𝐧、𝐋、𝐕は同じ空間に無ければいけません。
接線空間の法線とワールド空間の光の方向の内積を取ると、物体が回るとライティングが変わってしまいます。

12.9 実際のゲームやエンジンでの使われ方

  • ベイク:アーティストはとても細かいモデル(何百万もの三角形)を彫刻し、簡単なゲーム用のモデルを作り、ツール(Blender、Substance、Marmoset、xNormal)で其の差を法線マップにベイク(焼き込み)します。キャラクターの皺、縫い目、ボタンが形状無しで存在するのは此の為です。
  • MikkTSpace:メッシュの接線は、ベイクするツールとエンジンで全く同じ方法で計算しなければならず、そうでないとベイクに継ぎ目や間違った陰が出ます。標準はMorten MikkelsenのMikkTSpaceで、Blender、Unity、Unreal、glTFが使っています。
  • 頂点データの接線:メッシュは頂点毎に接線をvec4で持ちます。xyzが𝐓、wは鏡映したUVの為の利き手(handedness)で+1か−1です。シェーダーは𝐁=w(𝐍×𝐓)を計算します。そういうメッシュの読み込みはOpenGLシリーズで扱います。
  • 2チャンネル圧縮:表面から外を向いた単位法線はnz=1−nx2−ny2なので、エンジンはxとyだけを保存し(PCではBC5、モバイルではEAC RG11)、zをシェーダーで復元する事が良くあります。
  • ディテール法線マップ:レンガの形の為の大きな法線マップと、細かい粒の為の何度も繰り返す小さな法線マップを組み合わせます。単純な平均ではなく、ブレンドの式(例えば「ホワイトアウト」:normalize(vec3(a.xy + b.xy, a.z * b.z)))で混ぜます。
  • 視差マッピング:法線マッピングはライティングを直しますが、横から見るとテクスチャはまだ平らに表面の上を滑ります。視差マッピングと視差遮蔽マッピングは高さマップを使って視線方向にUVをずらし、レンガが飛び出している様にも見せます。
  • トライプラナーマッピング:地形や岩には良いUVが無い事が良くあります。トライプラナーマッピングはx、y、z方向に投影した3回のサンプリングを法線で混ぜます。法線マップは投影毎に自分の小さなTBNが必要です。
  • 事前計算した接線無しで:プロシージャルな表面では、フラグメントシェーダーが位置とUVのdFdxとdFdyからTBNを作る事が出来ます。Christian Schülerが広めた手法で、12.5節の微分の技の一般版です。

12.10 まとめ

  • ライティングは法線しか見ないので、法線のテクスチャで形状を増やさずに細部を足せます。輪郭を変えたり本物の影を落としたりは出来ません。
  • 高さマップからは𝐧=normalize(−s∂h/∂x,−s∂h/∂y,1)で法線が得られます。2つの傾きの方向の外積です。傾きは中心差分で測ります。
  • 法線は0.5𝐧+0.5で保存し、2c−1で読み戻します。平らなら(128,128,255)で、だから法線マップは青いです。
  • 複数のテクスチャは複数のユニットを使います:glActiveTexture(GL_TEXTURE1)、バインド、glUniform1i(…, 1)。アクティブなユニットはグローバルな状態なので、ユニット0のテクスチャを変える前に戻して下さい。
  • 法線はシェーダーでも計算出来ます。中心差分(4回のサンプリング、滑らか)か、dFdxとdFdy(タダ、ブロック状、簡単な形では画面と揃った面だけ)です。
  • 法線マップは接線空間にあります。𝐓𝐁𝐍=[𝐓𝐁𝐍](mat3(T, B, N))がライティングの空間に変換し、其の転置で戻ります。
  • 球では:atanとasinでUV、𝐓=normalize(Nz,0,−Nx)、𝐁=𝐍×𝐓。
  • 緑チャンネルの流儀、戻し忘れ、正規化、補間された接線のグラム・シュミット、sRGB、空間の混在に注意。

12.11 課題

  1. ★ 或るテクセルの隣の高さが、左0.75、右0.25、下0.5、上0.5です。強さs=3で(正規化した)法線と、保存されるRGBのバイト(Cのコードの様に255を掛けて0.5を足し、切り捨てる)を計算して下さい。どちらに傾いていますか?
  2. ★ 球の上の或る点で𝐍=(0,0.6,0.8)です。𝐓=normalize(Nz,0,−Nx)と𝐁=𝐍×𝐓を計算し、𝐁が「球の上向き」である事を確かめ、接線空間の法線(0,0.6,0.8)をTBN行列で変換して下さい。
  3. ★★ 強さをアニメーションさせて下さい。正規化する前に、戻した法線のxとyを0.5 + 0.5 * sin(uTime)倍して、凸凹が時間で大きくなったり小さくなったりする様にします。0の時は壁が完全に平らに、1の時は12.4節の法線マップの壁と同じに見える筈です。
  4. ★★ デバッグの為に法線を色で表示して下さい。球で、ライティングした色の代わりにn * 0.5 + 0.5を出力します。球の右側はピンクっぽく、上は緑っぽく、レンガは小さな色の変化として見える筈です。其の後T * 0.5 + 0.5を出力し、見える色を説明して下さい。
  5. ★★ zを復元して下さい。法線マップから.rgだけを戻し、n.z = sqrt(max(1.0 - dot(n.xy, n.xy), 0.0))を計算します。壁は12.4節と全く同じに見えなければいけません。何故此れがBC5圧縮が2チャンネルしか保存しない理由なのでしょうか?
  6. ★★ 第11章と第12章を組み合わせて下さい。法線マップを貼った球で、レンガの色の代わりに第11章のテスト用テクスチャuTexture(ユニット0)を同じUVでアルベドとして使います。チェッカーボードとFがレンガの上に描かれた様に見える筈です。其の後、法線マップの緑チャンネルを反転させ、どの斜面が変わるか説明して下さい。
  7. ★★★ 壁に簡単な視差マッピングを付けて下さい。サンプリングの前に、hを高さ、viewTangentを接線空間での目への方向として、uv += viewTangent.xy / viewTangent.z * h * 0.03でUVをずらします。今の真正面からの視点では何も起きないので、目の位置をマウスに依存させて下さい(例えばV = normalize(vec3(mouse, 1.0) - pos))。目が動くと、レンガが目地の上を滑る様に見える筈です。とても浅い角度では何故効果がおかしくなるのでしょうか?

DIFFをダウンロード

ファイル: a/main.c | b/main.c

前新
@@ -288,6 +288,134 @@ int keyPressed(GLFWwindow *window, int key, int *wasDown) {
288 return pressed; 288 return pressed;
289 } 289 }
290   290  
    291 // ピクセルデータからテクスチャを作る(TEX_SIZE×TEX_SIZE、ミップマップ付き、GL_REPEAT)
    292 GLuint uploadTexture(GLint internalFormat, GLenum format, const unsigned char *pixels) {
    293 GLuint texture;
    294 glGenTextures(1, &texture);
    295 glBindTexture(GL_TEXTURE_2D, texture);
    296 glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, TEX_SIZE, TEX_SIZE, 0,
    297 format, GL_UNSIGNED_BYTE, pixels);
    298 glGenerateMipmap(GL_TEXTURE_2D);
    299 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    300 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    301 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    302 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    303 return texture;
    304 }
    305  
    306 // GLSLのsmoothstepと同じ(0〜1の滑らかな段差)
    307 float smoothstepf(float edge0, float edge1, float x) {
    308 float t = (x - edge0) / (edge1 - edge0);
    309 if (t < 0.0f) t = 0.0f;
    310 if (t > 1.0f) t = 1.0f;
    311 return t * t * (3.0f - 2.0f * t);
    312 }
    313  
    314 // 整数の座標から0〜1の疑似乱数を作るハッシュ(第8章と同じ考え方)
    315 float hash2(int x, int y) {
    316 unsigned int h = (unsigned int)x * 374761393u + (unsigned int)y * 668265263u;
    317 h = (h ^ (h >> 13)) * 1274126177u;
    318 return (float)((h ^ (h >> 16)) & 0xffffu) / 65535.0f;
    319 }
    320  
    321 // cellテクセル毎の値ノイズ(TEX_SIZEで繰り返すので、テクスチャの継ぎ目が見えない)
    322 float valueNoise(int x, int y, int cell) {
    323 int n = TEX_SIZE / cell;
    324 int cx = x / cell, cy = y / cell;
    325 float fx = smoothstepf(0.0f, 1.0f, (float)(x % cell) / cell);
    326 float fy = smoothstepf(0.0f, 1.0f, (float)(y % cell) / cell);
    327 float a = hash2(cx % n, cy % n);
    328 float b = hash2((cx + 1) % n, cy % n);
    329 float c = hash2(cx % n, (cy + 1) % n);
    330 float d = hash2((cx + 1) % n, (cy + 1) % n);
    331 return (a + (b - a) * fx) * (1.0f - fy) + (c + (d - c) * fx) * fy;
    332 }
    333  
    334 // レンガの高さマップ(0〜1)を作る:レンガは高く、目地は低く、レンガの縁は斜め
    335 float *createBrickHeights(void) {
    336 float *heights = (float *)malloc(TEX_SIZE * TEX_SIZE * sizeof(float));
    337 if (!heights) {
    338 puts("高さマップのメモリ確保に失敗しました");
    339 return NULL;
    340 }
    341  
    342 for (int y = 0; y < TEX_SIZE; y++) {
    343 for (int x = 0; x < TEX_SIZE; x++) {
    344 // レンガは64×32テクセル、1段毎に半分ずらす
    345 int row = y / 32;
    346 int bx = (x + (row % 2) * 32) % 64;
    347 int by = y % 32;
    348  
    349 // レンガの一番近い縁までの距離(テクセル)
    350 float ex = fminf(bx + 0.5f, 64.0f - (bx + 0.5f));
    351 float ey = fminf(by + 0.5f, 32.0f - (by + 0.5f));
    352 float e = fminf(ex, ey);
    353  
    354 // 縁から2テクセルは目地(高さ0)、其処から4テクセルかけて斜めに上がる
    355 float h = smoothstepf(2.0f, 6.0f, e);
    356  
    357 // レンガの表面の細かい凸凹
    358 float rough = 0.6f * valueNoise(x, y, 8) + 0.4f * valueNoise(x, y, 4);
    359 h *= 0.8f + 0.2f * rough;
    360  
    361 heights[y * TEX_SIZE + x] = h;
    362 }
    363 }
    364 return heights;
    365 }
    366  
    367 // 高さマップをそのまま1チャンネルのテクスチャにする(GL_R8)
    368 GLuint createHeightTexture(const float *heights) {
    369 if (!heights) return 0;
    370 unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE);
    371 if (!pixels) return 0;
    372 for (int i = 0; i < TEX_SIZE * TEX_SIZE; i++) {
    373 pixels[i] = (unsigned char)(heights[i] * 255.0f + 0.5f);
    374 }
    375 GLuint texture = uploadTexture(GL_R8, GL_RED, pixels);
    376 free(pixels);
    377 return texture;
    378 }
    379  
    380 // 高さマップから法線マップを作る(中心差分、OpenGLの「緑が上」の向き)
    381 GLuint createNormalTexture(const float *heights, float strength) {
    382 if (!heights) return 0;
    383 unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE * 4);
    384 if (!pixels) return 0;
    385  
    386 for (int y = 0; y < TEX_SIZE; y++) {
    387 for (int x = 0; x < TEX_SIZE; x++) {
    388 // 隣のテクセル(端では反対側に回り込む、GL_REPEATと同じ)
    389 int xl = (x + TEX_SIZE - 1) % TEX_SIZE, xr = (x + 1) % TEX_SIZE;
    390 int yd = (y + TEX_SIZE - 1) % TEX_SIZE, yu = (y + 1) % TEX_SIZE;
    391  
    392 // 1テクセル進んだ時の高さの変化
    393 float dhdx = (heights[y * TEX_SIZE + xr] - heights[y * TEX_SIZE + xl]) * 0.5f;
    394 float dhdy = (heights[yu * TEX_SIZE + x] - heights[yd * TEX_SIZE + x]) * 0.5f;
    395  
    396 // 法線 = (-s・dh/dx, -s・dh/dy, 1) を正規化
    397 float nx = -strength * dhdx;
    398 float ny = -strength * dhdy;
    399 float nz = 1.0f;
    400 float len = sqrtf(nx * nx + ny * ny + nz * nz);
    401 nx /= len;
    402 ny /= len;
    403 nz /= len;
    404  
    405 // -1〜1を0〜255に詰める
    406 unsigned char *p = &pixels[(y * TEX_SIZE + x) * 4];
    407 p[0] = (unsigned char)((nx * 0.5f + 0.5f) * 255.0f + 0.5f);
    408 p[1] = (unsigned char)((ny * 0.5f + 0.5f) * 255.0f + 0.5f);
    409 p[2] = (unsigned char)((nz * 0.5f + 0.5f) * 255.0f + 0.5f);
    410 p[3] = 255;
    411 }
    412 }
    413  
    414 GLuint texture = uploadTexture(GL_RGBA8, GL_RGBA, pixels);
    415 free(pixels);
    416 return texture;
    417 }
    418  
291 int main(int argc, char **argv) { 419 int main(int argc, char **argv) {
292 (void)argc; 420 (void)argc;
293 (void)argv; 421 (void)argv;
@@ -380,6 +508,12 @@ int main(int argc, char **argv) {
380 setFilter(filterMode); 508 setFilter(filterMode);
381 setWrap(wrapMode); 509 setWrap(wrapMode);
382   510  
    511 // レンガの法線マップと高さマップを作る(第12章)
    512 float *heights = createBrickHeights();
    513 GLuint normalMap = createNormalTexture(heights, 3.0f);
    514 GLuint heightMap = createHeightTexture(heights);
    515 free(heights);
    516  
383 int reloadWasDown = 0; 517 int reloadWasDown = 0;
384 int filterWasDown = 0; 518 int filterWasDown = 0;
385 int wrapWasDown = 0; 519 int wrapWasDown = 0;
@@ -390,6 +524,8 @@ int main(int argc, char **argv) {
390 if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1); 524 if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1);
391   525  
392 // Fキーでフィルタリング、Wキーでラップモードを切り替える 526 // Fキーでフィルタリング、Wキーでラップモードを切り替える
    527 // (アクティブなユニットは前のフレームの最後のままなので、ユニット0に戻す)
    528 glActiveTexture(GL_TEXTURE0);
393 glBindTexture(GL_TEXTURE_2D, texture); 529 glBindTexture(GL_TEXTURE_2D, texture);
394 if (keyPressed(window, GLFW_KEY_F, &filterWasDown)) { 530 if (keyPressed(window, GLFW_KEY_F, &filterWasDown)) {
395 filterMode = (filterMode + 1) % 3; 531 filterMode = (filterMode + 1) % 3;
@@ -437,6 +573,14 @@ int main(int argc, char **argv) {
437 glBindTexture(GL_TEXTURE_2D, texture); 573 glBindTexture(GL_TEXTURE_2D, texture);
438 glUniform1i(glGetUniformLocation(shaderProgram, "uTexture"), 0); 574 glUniform1i(glGetUniformLocation(shaderProgram, "uTexture"), 0);
439   575  
    576 // 法線マップはユニット1、高さマップはユニット2
    577 glActiveTexture(GL_TEXTURE1);
    578 glBindTexture(GL_TEXTURE_2D, normalMap);
    579 glUniform1i(glGetUniformLocation(shaderProgram, "uNormalMap"), 1);
    580 glActiveTexture(GL_TEXTURE2);
    581 glBindTexture(GL_TEXTURE_2D, heightMap);
    582 glUniform1i(glGetUniformLocation(shaderProgram, "uHeightMap"), 2);
    583  
440 // クアッドを描画 584 // クアッドを描画
441 glBindVertexArray(VAO); 585 glBindVertexArray(VAO);
442 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); 586 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
@@ -451,6 +595,8 @@ int main(int argc, char **argv) {
451 glDeleteBuffers(1, &VBO); 595 glDeleteBuffers(1, &VBO);
452 glDeleteBuffers(1, &EBO); 596 glDeleteBuffers(1, &EBO);
453 glDeleteTextures(1, &texture); 597 glDeleteTextures(1, &texture);
    598 glDeleteTextures(1, &normalMap);
    599 glDeleteTextures(1, &heightMap);
454 glDeleteProgram(shaderProgram); 600 glDeleteProgram(shaderProgram);
455 glfwDestroyWindow(window); 601 glfwDestroyWindow(window);
456 glfwTerminate(); 602 glfwTerminate();

ファイル: a/shader/playground.glsl | b/shader/playground.glsl

前新
@@ -5,20 +5,54 @@ out vec4 out_color;
5   5  
6 uniform float uTime; 6 uniform float uTime;
7 uniform vec2 uResolution; 7 uniform vec2 uResolution;
8 uniform sampler2D uTexture; 8 uniform vec2 uMouse;
    9 uniform sampler2D uNormalMap;
    10 uniform sampler2D uHeightMap;
    11  
    12 const float PI = 3.14159265;
9   13  
10 void main() { 14 void main() {
11 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y; 15 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
12   16  
13 // 床の上の点:地平線(p.y = 0)に近い程遠い(距離は 1 / -p.y) 17 // 半径0.85の球(第9章の球と同じ考え方:zは x² + y² + z² = 1 から)
14 float depth = 1.0 / max(-p.y, 0.001); 18 vec2 q = p / 0.85;
15 vec2 uv = vec2(p.x * depth, depth) * 0.5; 19 float r2 = dot(q, q);
16 uv.y += uTime * 0.25; // 前に進む 20 vec3 N = vec3(q, sqrt(max(1.0 - r2, 0.0))); // 半径1の球では位置 = 法線
    21 vec3 pos = vec3(p, N.z * 0.85);
    22  
    23 // 球面のUV:経度がu、緯度がv(uを時間でずらして自転させる)
    24 vec2 uv;
    25 uv.x = atan(N.x, N.z) / (2.0 * PI) + 0.5 + uTime * 0.03;
    26 uv.y = asin(clamp(N.y, -1.0, 1.0)) / PI + 0.5;
    27 uv *= vec2(4.0, 2.0); // 横に4回、縦に2回繰り返すとレンガが歪まない
    28  
    29 // 接線空間の基底:Tはuが増える方向、Bはvが増える方向
    30 vec3 T = vec3(N.z, 0.0, -N.x);
    31 T = length(T) > 1e-4 ? normalize(T) : vec3(1.0, 0.0, 0.0); // 極ではTが決まらない
    32 vec3 B = cross(N, T);
    33 mat3 TBN = mat3(T, B, N); // 列がT、B、N
    34  
    35 // 法線マップの法線(接線空間)をビュー空間に変換する
    36 vec3 nTangent = texture(uNormalMap, uv).rgb * 2.0 - 1.0;
    37 vec3 n = normalize(TBN * nTangent);
    38  
    39 // 光はマウスの位置、球の手前1.5
    40 vec2 mouse = (2.0 * uMouse - uResolution) / uResolution.y;
    41 vec3 L = normalize(vec3(mouse, 1.5) - pos);
    42 vec3 V = vec3(0.0, 0.0, 1.0);
    43  
    44 float h = texture(uHeightMap, uv).r;
    45 vec3 albedo = mix(vec3(0.3, 0.28, 0.25), vec3(0.5, 0.2, 0.12), smoothstep(0.05, 0.4, h));
    46  
    47 // Blinn-Phong
    48 float diffuse = max(dot(n, L), 0.0);
    49 vec3 H = normalize(L + V);
    50 float specular = pow(max(dot(n, H), 0.0), 32.0) * 0.25;
    51 vec3 color = albedo * (0.05 + diffuse) + vec3(specular);
17   52  
18 // texture()はifの外で呼ぶ(理由は本文で) 53 // 球の外は背景
19 vec3 floorColor = texture(uTexture, uv).rgb; 54 if (r2 > 1.0) color = vec3(0.02, 0.025, 0.04);
20 vec3 skyColor = vec3(0.45, 0.6, 0.85);    
21 vec3 color = p.y < 0.0 ? floorColor : skyColor;    
22   55  
    56 color = pow(color, vec3(1.0 / 2.2));
23 out_color = vec4(color, 1.0); 57 out_color = vec4(color, 1.0);
24 } 58 }


コメント(0)

まだコメントはありません。