11. テクスチャとサンプリング

此処迄、画面上の全ての色は数式で計算して来ました。
グラデーション、図形、ノイズ、ライティング、全部そうです。
でも本物のゲームには、数式では書けない色も必要です。
木の床、人の顔、看板の文字等です。
其れらはテクスチャから来ます。
テクスチャとはGPUのメモリ上にある画像で、シェーダーは其の好きな位置を読む事が出来ます。
此の章では、Cでテクスチャを作り、プレイグラウンドのシェーダーに渡し、シェーダーがテクスチャを読む時に実際に何が起きているのかを詳しく見ていきます。
UV座標、ラップモード、フィルタリング、ミップマップ、そして画像を動かしたり変えたりする幾つかのエフェクトです。
画像ファイルの読み込みはOpenGLシリーズで扱うので、此処では画像を自分で生成します。

11.1 テクスチャとは?

テクスチャは、GPU上に保存された色の値の格子です。
画面のピクセルと混同しない様に、其の格子の1つの要素をテクセル(texture element)と呼びます。
今回のテスト用テクスチャは256 × 256テクセルで、其々のテクセルが赤・緑・青・アルファを8ビットの数値で持っています。

シェーダーは「37番目のテクセルを下さい」とは言いません。
代わりにUV座標で位置を指定します。
uは左端の0から右端の1迄、vは下端の0から上端の1迄です。
テクスチャの大きさは関係無いので、とても便利です。
同じシェーダーが64 × 64のテクスチャでも4096 × 4096のテクスチャでも動きます。
下の図は、UV空間に置いた小さな4 × 4のテクスチャです:

UV空間の4×4テクスチャ。テクセルの中心は0.125、0.375、0.625、0.875にある

其々のテクセルは小さな正方形を覆い、其の中心は端から半テクセル内側にあります。
幅がWテクセルのテクスチャでは、テクセル番号i(0から数える)はuでiWからi+1W迄を覆い、中心は次の位置です:

ui=i+0.5W

従って図(W=4)では、テクセル0の中心はu=0.5/4=0.125、テクセル3の中心はu=3.5/4=0.875です。
u=0は最初のテクセルの中心ではなく、其の左端である事に注意して下さい。
此の半テクセルのずれは、11.6節で自分でフィルタリングを書く時にもう一度出て来ます。

シェーダーが或るUVの色を聞くと、GPUは次の3つを行います:

  1. ラップ:UVが0〜1の外なら、其れが何を意味するか決める(繰り返し、鏡、クランプ等)。
  2. フィルタリング:UVがテクセルの中心にピッタリ当たる事は殆ど無いので、近くのテクセルをどう組み合わせるか決める。
  3. 結果をvec4として返す。

此れを行うハードウェアをサンプラーと呼び、テクスチャを読む事をサンプリングと呼びます。
ラップもフィルタリングもCから設定するので、先ずは其方から始めます。

注意: OpenGLでは、アップロードした最初の行のテクセルが一番下の行(v=0)になります。画像ファイルやVulkan・Direct3Dは一番上の行を最初に置きます。此の事は線型代数シリーズの第1章で説明しましたが、多くのOpenGLプログラムが画像を読み込む時に上下反転させるのは此の為です。

11.2 Cでテクスチャを作る

第9章・第10章のライティングのコードではなく、第5章のプレイグラウンドから始め直します。
此の章のDIFFは第5章との差分で、第12章は此の章の続きです。
全てのサンプリングの効果が見易い画像が欲しいです:

  • 32 × 32テクセルのチェッカーボード:テクセルや引き伸ばしが見える様に。
  • 色のグラデーション(右に行く程赤く、上に行く程緑):uとvが何処を向いているか見える様に。
  • 大きな白いFの文字:Fはどの方向に反転しても見た目が変わるから。
  • 右上の細い白い輪:曲線はフィルタリングの違いが良く見えるから。

main.cのmain()の上に、次の関数を追加します:

// テクスチャの大きさ(テクセル)
#define TEX_SIZE 256

// テスト用のテクスチャをCで作る(256×256、RGBA、1チャンネル8ビット)
// 1行目(y = 0)がテクスチャの一番下(v = 0)になる
GLuint createTestTexture(void) {
  unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE * 4);
  if (!pixels) {
    puts("テクスチャのメモリ確保に失敗しました");
    return 0;
  }

  for (int y = 0; y < TEX_SIZE; y++) {
    for (int x = 0; x < TEX_SIZE; x++) {
      // グラデーション:右に行く程赤く、上に行く程緑になる
      float r = (float)x / (TEX_SIZE - 1);
      float g = (float)y / (TEX_SIZE - 1);
      float b = 0.6f;

      // 32×32テクセルのチェッカーボード(暗いマスは暗くする)
      if ((x / 32 + y / 32) % 2 == 1) {
        r *= 0.35f;
        g *= 0.35f;
        b *= 0.35f;
      }

      // 「F」の文字(上下左右が反転したらすぐ分かる様に)
      int fx = x - 80, fy = y - 64;
      int inF = (fx >= 0 && fx < 24 && fy >= 0 && fy < 128)     // 縦棒
             || (fx >= 0 && fx < 96 && fy >= 104 && fy < 128)   // 上の横棒
             || (fx >= 0 && fx < 72 && fy >= 56 && fy < 80);    // 真ん中の横棒

      // 右上の白い輪(フィルタリングの違いが見える様に)
      float dx = x + 0.5f - 216.0f, dy = y + 0.5f - 216.0f;
      float d = sqrtf(dx * dx + dy * dy);
      int inRing = d > 20.0f && d < 24.0f;

      if (inF || inRing) r = g = b = 1.0f;

      unsigned char *p = &pixels[(y * TEX_SIZE + x) * 4];
      p[0] = (unsigned char)(r * 255.0f + 0.5f);
      p[1] = (unsigned char)(g * 255.0f + 0.5f);
      p[2] = (unsigned char)(b * 255.0f + 0.5f);
      p[3] = 255;
    }
  }

  // テクスチャオブジェクトを作り、ピクセルをGPUに送る
  GLuint texture;
  glGenTextures(1, &texture);
  glBindTexture(GL_TEXTURE_2D, texture);
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, TEX_SIZE, TEX_SIZE, 0,
               GL_RGBA, GL_UNSIGNED_BYTE, pixels);
  free(pixels);

  // ミップマップ(縮小版の画像の列)を自動で作る
  glGenerateMipmap(GL_TEXTURE_2D);

  // 範囲外の色(GL_CLAMP_TO_BORDERの時に使う)
  float border[] = { 1.0f, 0.0f, 1.0f, 1.0f };
  glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border);
  return texture;
}

ループは普通のCです。
テクセル毎に0〜1の色を計算し、0〜255のバイトとして保存します(+ 0.5fは切り捨てではなく四捨五入にする為です)。
テクセル(x,y)は(y * TEX_SIZE + x) * 4バイト目にあります。
1テクセル4バイトで、行が順番に並んでいます。
dxとdyの+ 0.5fは、11.1節と同じ様にテクセルの中心から測る為です。

OpenGLの部分は短いですが、全ての呼び出しに意味があります:

  • glGenTextures(1, &texture)は新しいテクスチャオブジェクトの名前(番号)を作ります。此の時点ではメモリはまだ確保されません。
  • glBindTexture(GL_TEXTURE_2D, texture)は其れを「現在の」2Dテクスチャにします。此の後のglTex…呼び出しは全て、引数で渡したテクスチャではなく、バインドされているテクスチャを変更します。
  • glTexImage2Dはメモリを確保し、ピクセルを其処にコピーします。此の呼び出しの後は、自分のコピーをfreeしても大丈夫です。
  • glGenerateMipmapは、11.7節で必要になる縮小版の画像を作ります。
  • glTexParameterfv(…, GL_TEXTURE_BORDER_COLOR, …)はサンプリング設定の1つを設定します。他の設定は下でglTexParameteriを使って設定します。

glTexImage2Dは引数が沢山あります:

引数 今回の値 意味
ターゲット GL_TEXTURE_2D バインドされているテクスチャの種類
レベル 0 アップロードするミップマップレベル(0 = 原寸の画像)
内部フォーマット GL_RGBA8 GPUでの保存形式:4チャンネル、其々8ビット
幅、高さ 256, 256 テクセル単位の大きさ
ボーダー 0 現代のOpenGLでは常に0
フォーマット GL_RGBA 自分のデータのチャンネルの順番
型 GL_UNSIGNED_BYTE 自分のデータの1チャンネルの型
データ pixels 自分のデータへのポインタ(NULLなら確保だけ)

内部フォーマットはGPU側を、フォーマットと型は渡す配列を表します。
違う場合はOpenGLが変換してくれます。

注意: OpenGLはデフォルトで、データの各行が4バイトの倍数の位置から始まると思っています(GL_UNPACK_ALIGNMENTが4)。今回の行は256 × 4バイトなので問題ありません。RGBのデータで幅が奇数の場合は、先にglPixelStorei(GL_UNPACK_ALIGNMENT, 1)が必要です。其れを忘れると画像が斜めにずれます。

キーボードでフィルタリングとラップモードを切り替える

実行中にサンプリング設定を切り替えたいので、次は2つの小さな表と2つの補助関数です。
名前の意味はまだ分からなくて大丈夫です。
ラップモードは11.5節、フィルタリングは11.6節と11.7節で説明します。

// フィルタリングの種類(Fキーで切り替え)
typedef struct {
  GLint minFilter;
  GLint magFilter;
  const char *name;
} FilterMode;

static const FilterMode filterModes[] = {
  { GL_NEAREST, GL_NEAREST, "GL_NEAREST" },
  { GL_LINEAR, GL_LINEAR, "GL_LINEAR" },
  { GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, "GL_LINEAR_MIPMAP_LINEAR(ミップマップ)" },
};

// ラップモードの種類(Wキーで切り替え)
typedef struct {
  GLint mode;
  const char *name;
} WrapMode;

static const WrapMode wrapModes[] = {
  { GL_REPEAT, "GL_REPEAT" },
  { GL_MIRRORED_REPEAT, "GL_MIRRORED_REPEAT" },
  { GL_CLAMP_TO_EDGE, "GL_CLAMP_TO_EDGE" },
  { GL_CLAMP_TO_BORDER, "GL_CLAMP_TO_BORDER" },
};

// 今バインドされているテクスチャにフィルタリングを設定
void setFilter(int mode) {
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filterModes[mode].minFilter);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filterModes[mode].magFilter);
  printf("フィルタリング: %s\n", filterModes[mode].name);
}

// 今バインドされているテクスチャにラップモードを設定(S = u方向、T = v方向)
void setWrap(int mode) {
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrapModes[mode].mode);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrapModes[mode].mode);
  printf("ラップモード: %s\n", wrapModes[mode].name);
}

// キーが押された瞬間だけ1を返す(押しっぱなしでは繰り返さない)
int keyPressed(GLFWwindow *window, int key, int *wasDown) {
  int down = glfwGetKey(window, key) == GLFW_PRESS;
  int pressed = down && !*wasDown;
  *wasDown = down;
  return pressed;
}

OpenGLはテクスチャの軸をUとVではなくSとTと呼ぶので、GL_TEXTURE_WRAP_Sがu方向のラップモードです。
keyPressedは、第5章でRキーに使った「前のフレームでは離されていたか?」と同じ仕組みです。
3つのキーで使える様に関数にしました。

シェーダーの為にテクスチャをバインドする

main()の頂点属性の後で、テクスチャを作り、最初の設定を選びます:

  // テスト用のテクスチャを作る(最初はミップマップ付きのGL_LINEARとGL_REPEAT)
  GLuint texture = createTestTexture();
  int filterMode = 2;
  int wrapMode = 0;
  setFilter(filterMode);
  setWrap(wrapMode);

  int reloadWasDown = 0;
  int filterWasDown = 0;
  int wrapWasDown = 0;

レンダリングループの最初で、FキーとWキーで表を順番に切り替えます。
RキーもkeyPressedを使う様にします:

    // Fキーでフィルタリング、Wキーでラップモードを切り替える
    glBindTexture(GL_TEXTURE_2D, texture);
    if (keyPressed(window, GLFW_KEY_F, &filterWasDown)) {
      filterMode = (filterMode + 1) % 3;
      setFilter(filterMode);
    }
    if (keyPressed(window, GLFW_KEY_W, &wrapWasDown)) {
      wrapMode = (wrapMode + 1) % 4;
      setWrap(wrapMode);
    }

    // Rキーでシェーダーを再読み込み(失敗したら古いシェーダーのまま)
    if (keyPressed(window, GLFW_KEY_R, &reloadWasDown)) {

そして他のユニフォームの後で、テクスチャをシェーダーに渡します:

    // テクスチャをユニット0にバインドし、サンプラーにユニットの番号を教える
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, texture);
    glUniform1i(glGetUniformLocation(shaderProgram, "uTexture"), 0);

最後に、他のオブジェクトと一緒にglDeleteTextures(1, &texture);で削除します。

何故テクスチャ其の物ではなく番号を渡すのでしょうか?
シェーダーはテクスチャオブジェクトを直接見る事が出来ません。
GPUにはテクスチャユニットが並んでいて(OpenGL 4.xのフラグメントシェーダーでは最低16個)、其々のユニットが1つのテクスチャをバインド出来ます。
シェーダーのサンプラーユニフォームが持っているのは、ユニットの番号です:

テクスチャオブジェクトがテクスチャユニット0にバインドされ、サンプラーユニフォームuTextureに0が設定される

glActiveTexture(GL_TEXTURE0)でユニット0を選び、glBindTextureでテクスチャを其処に入れ、glUniform1i(…, 0)でuTextureにユニット0を読む様に教えます。
テクスチャが1つだけだと大袈裟に見えますが、第12章では3つのテクスチャを同時に使い、其々が別のユニットに入ります。
ユニフォームを毎フレーム設定するのは、Rキーを押すと新しいプログラムが作られ、新しいプログラムでは全てのユニフォームが0に戻るからです。

注意: GLSL 4.20以降ではlayout(binding = 0) uniform sampler2D uTexture;と書けばglUniform1iは要りません。でもmacOSはOpenGL 4.1(GLSL 4.10)迄なので、此のシリーズのシェーダーでは使わず、Cからユニットを設定します。

11.3 最初のサンプリング

次はシェーダー側です。
shader/playground.glslを次の内容に置き換えます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform sampler2D uTexture; // テクスチャユニット0のテクスチャ

void main() {
  vec3 color = texture(uTexture, vUv).rgb;
  out_color = vec4(color, 1.0);
}

ビルドして実行すると、ウィンドウ全体にテクスチャが表示されます:

ウィンドウ全体に引き伸ばされたテスト用テクスチャ

新しい物が2つあります:

  • sampler2Dは「2Dテクスチャと其のサンプラー設定」を表す型です。不透明型(opaque type)なので、計算も代入も普通の変数への保存も出来ません。uniformか関数の引数にしかなれません。
  • texture(sampler, uv)はテクスチャのuvの位置をサンプリングし、vec4(赤、緑、青、アルファ)を返します。Cで設定したラップモードとフィルタリングが使われます。今回は色だけ欲しいので、.rgbのスウィズルで最初の3成分を取り出します。

vUvはフルスクリーンクアッドの0〜1のUV(第3章の頂点データ)なので、テクスチャはウィンドウにピッタリ収まります。
でもウィンドウは800 × 600でテクスチャは正方形なので、全体が横に引き伸ばされています。
チェッカーのマスが縦より横の方が長いですね。
代わりに第5章の縦横比を直した座標を使いましょう:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

void main() {
  // 縦が-1〜1、横は縦横比に合わせた座標(第5章)
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  // -1〜1の正方形を0〜1のUVにする
  vec2 uv = p * 0.5 + 0.5;

  vec3 color = texture(uTexture, uv).rgb;

  // 正方形の外は暗くする
  if (abs(p.x) > 1.0) color *= 0.25;

  out_color = vec4(color, 1.0);
}

ウィンドウの真ん中の正方形のテクスチャと、両側の暗くした繰り返し

pは縦に−1から1迄なので、p * 0.5 + 0.5は0から1迄になり、正方形になります。
正方形の左右ではuが0より小さく、又は1より大きくなり、テクスチャは単純に繰り返されます(ifで暗くしています)。
此れがCで選んだGL_REPEATラップモードです。
11.5節でまた戻って来ます。
色を見て下さい。
右に行く程赤く、上に行く程緑なので、約束通りuは右を、vは上を向いています。

11.4 UV座標:反転と鏡映

UVは只の数値なので、サンプリングの前に変えれば画像も一緒に変わります。
一番簡単なのは反転です:

  • u′=1−uは左右を反転させます。
  • v′=1−vは上下を反転させます。
  • 両方同時なら、画像が180°回転します。

次のシェーダーはウィンドウを4つの区画に分け、其々で違う反転をします:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

void main() {
  // 画面を2×2の区画に分ける
  vec2 cell = floor(gl_FragCoord.xy / uResolution * 2.0);  // 区画の番号(0か1)
  vec2 local = fract(gl_FragCoord.xy / uResolution * 2.0); // 区画の中の0〜1

  // 区画の中で縦横比を直し、真ん中に正方形を置く
  vec2 p = (local - 0.5) * vec2(uResolution.x / uResolution.y, 1.0) * 2.0;
  vec2 uv = p * 0.6 + 0.5;

  if (cell.x == 1.0) uv.x = 1.0 - uv.x; // 右の列:左右反転
  if (cell.y == 0.0) uv.y = 1.0 - uv.y; // 下の行:上下反転

  vec3 color = texture(uTexture, uv).rgb;

  // 0〜1の外は暗い灰色
  if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) color = vec3(0.08);

  out_color = vec4(color, 1.0);
}

テクスチャの4つのコピー:元の画像、左右反転、上下反転、180度回転

左上が元の画像、右上が左右反転、左下が上下反転、右下が両方です。
floorとfractは第7章で紹介しました。
floor(x)はx以下の最大の整数で、fract(x)=x−floor(x)は其の残りです。
画面の位置を2倍して其の2つに分けると、「どの区画か」と「区画の中の何処か」が得られます。

左下の画像は、普通の画像ファイル(一番上の行が最初)を反転せずにOpenGLに読み込んだ時と全く同じです。
テクスチャが上下逆さまに表示されたら、読み込み時に行を反転させるか、シェーダーで1−vを使って下さい。
但し、両方はやらない事!

注意: 第7章のルールは此処でも同じです。座標を変えると、画像は逆向きに動きます。uv * 2.0は画像を小さくし(2回繰り返す)、uv + 0.1は画像を左に動かします。描く場所ではなく、読む場所を変えているからです。

11.5 ラップモード

uが−0.3や1.7の時、texture()は何を返すべきでしょうか?
其れを決めるのがラップモードで、軸毎にGL_TEXTURE_WRAP_SとGL_TEXTURE_WRAP_Tで設定します。
幅Wテクセルのテクスチャで、良く使う4つのモードは次の通りです:

モード uに何が起きるか 式
GL_REPEAT テクスチャが永遠に繰り返す u′=fract(u)
GL_MIRRORED_REPEAT 繰り返し、1つ置きに鏡映 u′=1−abs(2fract(u/2)−1)
GL_CLAMP_TO_EDGE 端のテクセルが外側に引き伸ばされる u′=clamp(u,12W,1−12W)
GL_CLAMP_TO_BORDER 0〜1の外は決まったボーダー色 (式は無く、ボーダー色そのまま)

鏡映の式をu=1.25で確かめてみます。
u/2=0.625、其のfractは0.625、2⋅0.625−1=0.25、そして1−0.25=0.75です。
つまり1.25は0.75と同じ場所を読みます。
右端から4分の1戻った位置、鏡映です。
u=0.25(テクスチャの内側)なら、fract(0.125)=0.125、2⋅0.125−1=−0.75、1−0.75=0.25で、変わりません。
其の通りであるべきです。
GL_CLAMP_TO_EDGEのクランプの限界は最初と最後のテクセルの中心(11.1節)なので、一番外側のテクセルが外の何かと混ざる事はありません。

4つ全部を見る為に、UVが縦に−1から2迄になる様にズームアウトし、本物の0〜1のテクスチャの場所に黄色い枠を描きます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

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

  // UVを0〜1より広い範囲にする(縦は-1〜2)
  vec2 uv = p * 1.5 + 0.5;

  vec3 color = texture(uTexture, uv).rgb;

  // 元の0〜1の正方形の枠を黄色で描く
  float d = max(abs(uv.x - 0.5), abs(uv.y - 0.5)) - 0.5;
  if (abs(d) < 0.008) color = vec3(1.0, 0.9, 0.2);

  out_color = vec4(color, 1.0);
}

枠には第6章の矩形の距離を使っています。
dは0〜1の正方形の縁で丁度0になるので、abs(d) < 0.008が細い輪郭になります。
実行してWキーを3回押して下さい:

4つのラップモード:繰り返し、鏡映繰り返し、端でクランプ、ボーダーでクランプ

GL_REPEAT(左上)は、床、壁、草、水等、繰り返す物全てに使います。
GL_MIRRORED_REPEAT(右上)は、完全には繋がらないテクスチャの継ぎ目を隠せます。
隣同士のコピーが共通の縁で必ず一致するからです。
GL_CLAMP_TO_EDGE(左下)は一番外側の行と列を長い縞にします。
此処では変に見えますが、UI画像、スプライト、文字、フルスクリーンエフェクトでは此れが正しいモードです。
反対側の端から何かが「漏れて来て」はいけないからです。
GL_CLAMP_TO_BORDER(右下)はcreateTestTextureで設定したマゼンタのボーダー色を表示します。
シャドウマップで便利です。
「マップの外」は「影ではない」という意味にしたいからです。

注意: 0〜1の内側でもラップモードは関係します。リニアフィルタリングでは、右端の近くのサンプルは、GL_REPEATなら左端のテクセルも混ぜます。繰り返すテクスチャなら正しいですが、スプライトなら間違いで、反対側の色の細い線が出てしまいます。だからスプライトはGL_CLAMP_TO_EDGEを使います。

11.6 拡大時のフィルタリング:最近傍とバイリニア

画面とテクスチャが1ピクセル対1テクセルで揃う事は殆どありません。
ピクセルがテクセルより小さい(テクスチャが本来より大きく表示される、拡大)か、1ピクセルが多くのテクセルを覆う(テクスチャが小さく表示される、縮小)かのどちらかです:

拡大では1ピクセルがテクセルより小さく、縮小では1ピクセルが多くのテクセルを覆う

OpenGLには其々の場合のフィルター設定、GL_TEXTURE_MAG_FILTERとGL_TEXTURE_MIN_FILTERがあります。
先ずはテクスチャ右上の輪にズームインして、拡大から見てみましょう:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

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

  // 右上の輪の辺りを大きく拡大する(縦はvで0.2だけ)
  vec2 uv = p * 0.1 + vec2(0.82, 0.82);

  vec3 color = texture(uTexture, uv).rgb;
  out_color = vec4(color, 1.0);
}

画面の600ピクセルがテクスチャの0.2、つまり約51テクセルを表示するので、1テクセルは縦に約12ピクセルです。
Fキーを1回押すとGL_NEAREST、もう1回押すとGL_LINEARになります。
並べるとこうなります:

拡大した輪。左は四角いテクセルが見える最近傍フィルタリング、右は滑らかだがぼやけたリニアフィルタリング

  • GL_NEARESTは、UVを含む正方形のテクセル1つを返します。全てのテクセルが角張ったブロックとして見えます。ドット絵や、値を混ぜると意味が無くなるデータテクスチャ(ID、ルックアップテーブル)には此れが正しい選択です。
  • GL_LINEARは、近くの4つのテクセルの中心を混ぜます。縁は柔らかくなり、輪は丸く見えますが、ぼやけもします。3Dゲームの殆どのテクスチャは此れを使います。

バイリニアの式

「近くの4つのテクセルを混ぜる」事をバイリニア補間と呼びます。
線型補間(線型代数シリーズ第2章のmix)を横に2回、縦に1回行う物です。
mix(a,b,t)=a(1−t)+btでしたね。

先ず、UVを、テクセルの中心が整数になるテクセル座標に変換します。
中心iはu=(i+0.5)/Wにあるので、iについて解きます:

s=uW−0.5,t=vH−0.5

左下のテクセルは(⌊s⌋,⌊t⌋)で、残りの部分fx=fract(s)、fy=fract(t)が次のテクセルにどれだけ近いかを表します。
4つのテクセルの色をc00(左下)、c10(右下)、c01(左上)、c11(右上)と呼ぶと:

a=mix(c00,c10,fx)b=mix(c01,c11,fx)c=mix(a,b,fy)

4つのテクセルの中心の間のバイリニア補間。先ずx方向にaとbを求め、次にy方向に結果を求める

図の例で、灰色の値c00=0.2、c10=0.8、c01=0.4、c11=1.0、f=(0.3,0.6)の場合:

a=0.2⋅0.7+0.8⋅0.3=0.14+0.24=0.38b=0.4⋅0.7+1.0⋅0.3=0.28+0.30=0.58c=0.38⋅0.4+0.58⋅0.6=0.152+0.348=0.5

半テクセルのずれの例ももう1つ。
幅256テクセルのテクスチャでu=0.5なら、s=128−0.5=127.5です。
従って⌊s⌋=127、fx=0.5で、テクセル127と128の丁度真ん中です。
u=0.5はテクスチャの左半分と右半分の丁度境目なので、理に適っています。

texelFetchで自分でやってみる

ちゃんと理解出来たか確かめる為に、両方のフィルターを手で作り直してみましょう。
其の為に組み込み関数が2つ必要です:

  • textureSize(sampler, lod)は、ミップマップレベルlodの大きさをテクセル単位のivec2で返します。レベル0が原寸の画像なので、textureSize(uTexture, 0)はivec2(256, 256)です。
  • texelFetch(sampler, ivec2 texel, int lod)は、整数の番号でテクセルを丁度1つ読みます。フィルタリングもラップもしません。テクスチャの外の番号では結果が未定義なので、ラップは自分でする必要があります。

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

// GL_REPEATと同じ様に、テクセルの番号を0〜size-1に戻す
ivec2 wrapTexel(ivec2 i, ivec2 size) {
  return ivec2(mod(vec2(i), vec2(size)));
}

// 最近傍(GL_NEARESTと同じ事を手でする)
vec3 nearest(sampler2D tex, vec2 uv) {
  ivec2 size = textureSize(tex, 0);
  ivec2 i = ivec2(floor(uv * vec2(size)));
  return texelFetch(tex, wrapTexel(i, size), 0).rgb;
}

// バイリニア補間(GL_LINEARと同じ事を手でする)
vec3 bilinear(sampler2D tex, vec2 uv) {
  ivec2 size = textureSize(tex, 0);
  vec2 st = uv * vec2(size) - 0.5; // テクセルの中心が整数になる座標
  ivec2 i = ivec2(floor(st));      // 左下のテクセル
  vec2 f = fract(st);              // 4つのテクセルの間の位置(0〜1)

  vec3 c00 = texelFetch(tex, wrapTexel(i + ivec2(0, 0), size), 0).rgb;
  vec3 c10 = texelFetch(tex, wrapTexel(i + ivec2(1, 0), size), 0).rgb;
  vec3 c01 = texelFetch(tex, wrapTexel(i + ivec2(0, 1), size), 0).rgb;
  vec3 c11 = texelFetch(tex, wrapTexel(i + ivec2(1, 1), size), 0).rgb;

  // 先ず横に2回、次に縦に1回補間する
  return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
}

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

  // 左半分:手で書いた最近傍、右半分:手で書いたバイリニア
  vec3 color = p.x < 0.0 ? nearest(uTexture, uv) : bilinear(uTexture, uv);

  // 真ん中に区切り線
  if (abs(gl_FragCoord.x - uResolution.x * 0.5) < 1.0) color = vec3(0.0);

  out_color = vec4(color, 1.0);
}

結果は上のスクリーンショットと全く同じに見えます。
2つの画像をピクセル毎に比べてみたところ、最大の差は255分の1でした。
GPUは普通fを8ビットの精度でしか計算しないからです。
mod(x, y)はx−y⌊x/y⌋で、(負の整数に対する%演算子と違って)結果は必ず0からyの間になります。
従って−1はGL_REPEATと同じ様に255に戻ります。

普段はハードウェアにフィルタリングさせます。
其の方がずっと速いからです。
でも、テクスチャが画像ではなくデータを持っている時は、texelFetchが正しい道具です。
ルックアップテーブル、パーティクルの位置、ゲーム盤のマス、ディファードレンダリングのGバッファ等です。

11.7 縮小:エイリアシングとミップマップ

拡大は物をぼやけさせるだけです。
縮小はもっと悪くて、物を間違って表示します。
其れを見る為に、テクスチャを果てしない床にしてみましょう。

目が床から1の高さにあり、1離れたスクリーン越しに真っ直ぐ前を見ていると想像して下さい。
スクリーン上の高さがpy<0のピクセルは光線に沿って下を見ていて、相似な三角形から、其の光線は距離1/(−py)で床に当たります:

横から見た図:目からスクリーン上の−0.4の点を通る光線は、距離2.5で床に当たる

横方向も同じなので、床の上の位置はd=1/(−py)として(pxd,d)です。
地平線の近く(pyが0に近い)のピクセルはとても遠く、其処では隣同士のピクセルが多くのテクセル分離れています。

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;
uniform vec2 uResolution;
uniform sampler2D uTexture;

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

  // 床の上の点:地平線(p.y = 0)に近い程遠い(距離は 1 / -p.y)
  float depth = 1.0 / max(-p.y, 0.001);
  vec2 uv = vec2(p.x * depth, depth) * 0.5;
  uv.y += uTime * 0.25; // 前に進む

  // texture()はifの外で呼ぶ(理由は本文で)
  vec3 floorColor = texture(uTexture, uv).rgb;
  vec3 skyColor = vec3(0.45, 0.6, 0.85);
  vec3 color = p.y < 0.0 ? floorColor : skyColor;

  out_color = vec4(color, 1.0);
}

max(-p.y, 0.001)は、空(どうせ色を捨てる部分)で0や負の数で割らない為です。
下は地平線のすぐ下の部分を3つのフィルター設定で比べた物です(Fキーで切り替え):

地平線近くの床。最近傍とリニアではノイズだらけ、トライリニアのミップマップでは滑らか

GL_NEARESTとGL_LINEARでは、床の遠い部分がランダムな色の滅茶苦茶になり、チェッカーボードがある筈の場所に曲がった縞(モアレ)が出ます。
其処の1ピクセルは何十ものテクセルを覆っているのに、サンプラーは其の内の1つ(又は4つ)しか見ず、其れも殆ど偶然に選ばれます。
床が動くと其の偶然が毎フレーム変わり、ノイズがチラつきます。
此れをエイリアシングと呼び、細い線がギザギザに見えるのと同じ問題です。
GL_LINEARでも解決しません。
50個の中から4つを平均しても、やはりランダムに選んでいるのと同じだからです。

ミップマップ

解決策は、テクスチャの縮小版を前もって用意しておく事です。
其々の縮小版は、1つ前の物を平均して作ります。
createTestTextureのglGenerateMipmapが此れをしていました。
レベル1は128 × 128(各テクセルはレベル0の2 × 2テクセルの平均)、レベル2は64 × 64、という風に1 × 1迄続きます。
256は8回半分にすると1になるので、今回のテクスチャには9つのレベルがあります。
次のシェーダーは、texelFetchにレベル番号を渡して、レベル0〜5を同じ大きさに引き伸ばして表示します:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

void main() {
  vec3 color = vec3(0.08);

  // 240×240ピクセルの四角を3列×2行に並べる(左上がレベル0)
  vec2 pos = gl_FragCoord.xy - vec2(20.0, 40.0);
  vec2 cell = floor(pos / vec2(260.0, 280.0));
  vec2 local = (pos - cell * vec2(260.0, 280.0)) / 240.0; // 四角の中の0〜1

  if (all(greaterThanEqual(local, vec2(0.0))) && all(lessThan(local, vec2(1.0)))
      && cell.x >= 0.0 && cell.x < 3.0 && cell.y >= 0.0 && cell.y < 2.0) {
    int level = int(cell.x) + 3 * (1 - int(cell.y));  // 上の行が0〜2、下の行が3〜5
    ivec2 levelSize = textureSize(uTexture, level);   // 256、128、64……
    ivec2 texel = ivec2(local * vec2(levelSize));
    color = texelFetch(uTexture, texel, level).rgb;
  }

  out_color = vec4(color, 1.0);
}

テスト用テクスチャのミップマップレベル0〜5。256×256から8×8テクセル迄

lessThanとgreaterThanEqualはベクトルを成分毎に比べてbvec2を返し、allは全ての成分が真の時に真になります。
レベル3(32 × 32、左下)では輪が既にぼやけていて、レベル5(8 × 8)ではFが灰色の染みになっています。
元の32 × 32テクセルが各テクセルに平均されたからです。
其れだけ多くのテクセルを覆うピクセルにとっては、其の染みこそが正しい答えです。

全てのレベルを合わせても、メモリはレベル0の1+14+116+⋯=43倍なので、ミップマップのコストはメモリが3分の1増えるだけです。

レベルの選び方

GPUは、ピクセル毎に「1ピクセル右か1ピクセル上に動いたら、UVは何テクセル動くか?」と考えてレベルを選びます。
フラグメントは常に2 × 2のブロックで処理されるので、隣のピクセルの値との差を取る事が出来るのです。
GLSLでも同じ引き算が使えます:

  • dFdx(v)は、此のピクセルから横の隣のピクセルへvがどれだけ変わるか。
  • dFdy(v)は、其れの縦方向版。

𝐬=(uW,vH)(テクセル単位のUV)とすると、GPUは大体次の計算をします:

ρ=max(‖∂𝐬∂x‖,‖∂𝐬∂y‖),λ=log2ρ

∂𝐬∂xは只のdFdx(s)です。
λ(ラムダ)は詳細度(level of detail、LOD)です。
1ピクセル動くと1テクセル動くならλ=0で、レベル0を使います。
4テクセル動くならλ=2で、レベル2なら丁度1ピクセルに1テクセルです。
0より小さい値は拡大を意味し、代わりにMAGフィルターが使われます。
例えば1ピクセル動くと横に1.5テクセル、縦に6テクセル動くなら、ρ=6、λ=log26≈2.585で、レベル2とレベル3の間です。

自分で計算したλで床に色を付けてみましょう:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;
uniform vec2 uResolution;
uniform sampler2D uTexture;

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  float depth = 1.0 / max(-p.y, 0.001);
  vec2 uv = vec2(p.x * depth, depth) * 0.5;
  uv.y += uTime * 0.25;

  // 隣のピクセルに移るとテクセル単位で何個分進むか
  vec2 st = uv * vec2(textureSize(uTexture, 0));
  float rho = max(length(dFdx(st)), length(dFdy(st)));
  float lod = max(log2(rho), 0.0); // ミップマップレベル(0 = 元の画像)

  // レベルごとに色を付ける:0 青、1 水色、2 緑、3 黄、4 橙、5以上 赤
  vec3 levels[6] = vec3[](
    vec3(0.2, 0.2, 0.9), vec3(0.2, 0.8, 0.9), vec3(0.2, 0.8, 0.2),
    vec3(0.9, 0.9, 0.2), vec3(0.9, 0.5, 0.1), vec3(0.9, 0.1, 0.1));
  int level = min(int(lod), 5);
  vec3 tint = mix(levels[level], levels[min(level + 1, 5)], fract(lod));

  vec3 color = mix(texture(uTexture, uv).rgb, tint, 0.7);
  if (p.y >= 0.0) color = vec3(0.45, 0.6, 0.85);

  out_color = vec4(color, 1.0);
}

ミップマップレベルで色を付けた床。カメラの近くは青、緑と黄色を経て地平線で赤

カメラの近く(青)ではレベル0が使われ、床が遠くなる程レベルが上がります。
log2(x)は2を底とする対数(log28=3)で、vec3[](…)は配列のコンストラクタです。
6つのvec3の値を1つの定数配列にしています。
下の方の帯程幅が広い事に気付いたでしょうか。
各レベルは1つ前のレベルの2倍の距離を担当するからです。

縮小フィルター

GL_TEXTURE_MIN_FILTERには6つの値があります。
最初の単語はレベルの中でのフィルタリング方法、2番目の単語はレベルの間の選び方です:

縮小フィルター レベルの中 レベルの間
GL_NEAREST 最近傍 ミップマップ無し、常にレベル0
GL_LINEAR バイリニア ミップマップ無し、常にレベル0
GL_NEAREST_MIPMAP_NEAREST 最近傍 一番近い1つのレベル
GL_LINEAR_MIPMAP_NEAREST バイリニア 一番近い1つのレベル
GL_NEAREST_MIPMAP_LINEAR 最近傍 近い2つのレベルを混ぜる
GL_LINEAR_MIPMAP_LINEAR バイリニア 近い2つのレベルを混ぜる(「トライリニア」)

GL_LINEAR_MIPMAP_LINEARでλ=2.585なら、GPUはレベル2とレベル3を其々バイリニアでフィルタリングし、其れを0.585の重みで混ぜます。
其の為、レベルが切り替わる所に目に見える段差がありません。
1回のサンプリングで8テクセル読む事になりますが、GPUは此れを速く行う様に作られているので、3Dシーンではトライリニアフィルタリングが普通の選択です。

注意: OpenGLの縮小フィルターのデフォルトはGL_NEAREST_MIPMAP_LINEARで、ミップマップが必要です。テクスチャを作ってレベル0だけをアップロードし、glGenerateMipmapも縮小フィルターの設定も忘れると、テクスチャは「不完全」になり、全てのサンプリングが黒を返します。恐らく「テクスチャが真っ黒」バグの一番の原因です。

違いは動きで一番良く分かります。
次の動画では、左半分はtextureLodでレベル0を強制し、右半分は普通のtexture(レベルを自分で選ぶ)を使っています:

  // 左半分:レベル0だけ(ミップマップ無し)、右半分:GPUがレベルを選ぶ
  vec3 level0 = textureLod(uTexture, uv, 0.0).rgb;
  vec3 automatic = texture(uTexture, uv).rgb;
  vec3 color = p.x < 0.0 ? level0 : automatic;

textureLod(sampler, uv, lod)はtextureと同じですが、λを計算する代わりに渡したレベルを使います。
トライリニアの縮小フィルターでlod = 0.0なら、レベル0の普通のバイリニアフィルタリングで、GL_LINEARと同じです。

動く床の1フレーム。左はミップマップ無し、右はミップマップ有り

左側はチラつき、右側は落ち着いています。
でも右側は地平線の近くで平らな灰紫色になり、必要以上にぼやけてもいます。
ρは2つの方向の大きい方を取るからです。
床は横より縦の方がずっと潰れているので、レベルが一番悪い方向に合わせて選ばれてしまいます。
異方性フィルタリングは、潰れた方向に沿って複数のサンプルを取る事で此れを解決します。
OpenGL 4.6ではコア機能で、殆ど全ての環境(macOSも含む)で拡張機能GL_EXT_texture_filter_anisotropicとして使えます。
設定はglTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, 8.0f)です。
OpenGLシリーズで使います。

微分には一様な制御フローが必要

床のシェーダーでは、texture()をifの中ではなく前で呼びました。
理由は次の通りです。
dFdx、dFdy、そして(レベルを選ぶのに微分を使う)texture()は、2 × 2ブロックの隣のピクセルも同じコードを実行している必要があります。
ブロックの一部のピクセルだけがifに入ると、隣には引き算する値が無く、GLSLは結果を未定義としています。
textureLodとtexelFetchは微分を使わないので、何処で呼んでも安全です。
目安としては、texture()はmainの一番外側でサンプリングして結果を変数に入れ、選ぶのは其の後にする事です。

11.8 UVを動かす:スクロールと水

UVは只のvec2なので、此れ迄の章の全て(時間、サイン、ノイズ)で変える事が出来ます。
時間を足すとテクスチャがスクロールし(ベルトコンベア、雲、滝)、小さな波を足すと揺れます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;
uniform vec2 uResolution;
uniform sampler2D uTexture;

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

  // 1. ゆっくり右上へ流す
  uv += uTime * vec2(0.03, 0.02);

  // 2. 波の様にUVを少しだけずらす
  vec2 offset;
  offset.x = sin(uv.y * 20.0 + uTime * 2.0);
  offset.y = cos(uv.x * 16.0 + uTime * 1.6);
  uv += 0.012 * offset;

  // 3. 水っぽい青を混ぜる
  vec3 color = texture(uTexture, uv).rgb;
  color = mix(color, vec3(0.1, 0.35, 0.6), 0.35);

  out_color = vec4(color, 1.0);
}

動く水越しに見たテクスチャ。チェッカーの線が波打っている

横のずれはvに、縦のずれはuに依存するので、チェッカーボードの真っ直ぐな線が波になります。
振幅0.012は約3テクセルです。
目は歪んだ直線にとても敏感なので、小さな数値で十分です。
サインを第8章のノイズに置き換えると、多くのゲームの陽炎や水中のエフェクトになります。

11.9 簡単な画像エフェクト

ゲームのポストプロセスエフェクトは、レンダリングしたフレームを持つテクスチャをサンプリングするフラグメントシェーダーです。
今回のテスト用テクスチャを其のフレームの代わりにしましょう。
古典的なエフェクトを3つ、其々小さな関数で書きます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform sampler2D uTexture;

// グレースケール:人間の目の感度に合わせた重み付きの平均(内積)
vec3 grayscale(vec3 c) {
  float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
  return vec3(y);
}

// セピア:3×3行列を掛ける(GLSLのmat3は列ごとに書く)
vec3 sepia(vec3 c) {
  mat3 m = mat3(0.393, 0.349, 0.272,  // 1列目:Rの寄与
                0.769, 0.686, 0.534,  // 2列目:Gの寄与
                0.189, 0.168, 0.131); // 3列目:Bの寄与
  return min(m * c, vec3(1.0));
}

// ぼかし:周りの7×7テクセルの重み付き平均(ガウス分布の重み)
vec3 blur(sampler2D tex, vec2 uv) {
  vec2 texel = 1.0 / vec2(textureSize(tex, 0)); // 1テクセルのUVでの大きさ
  vec3 sum = vec3(0.0);
  float total = 0.0;
  for (int y = -3; y <= 3; y++) {
    for (int x = -3; x <= 3; x++) {
      float w = exp(-float(x * x + y * y) / 4.5); // σ = 1.5テクセル
      sum += w * texture(tex, uv + vec2(x, y) * texel).rgb;
      total += w;
    }
  }
  return sum / total;
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec2 uv = p * 0.5 + 0.5;
  vec3 color = texture(uTexture, uv).rgb;

  // 正方形を縦に4つに分けて、其々に違う効果を掛ける
  float band = floor((p.x + 1.0) * 2.0); // 0、1、2、3
  vec3 blurred = blur(uTexture, uv); // texture()を使うのでifの外で
  if (band == 1.0) color = grayscale(color);
  if (band == 2.0) color = sepia(color);
  if (band == 3.0) color = blurred;

  // 正方形の外は暗い灰色
  if (abs(p.x) > 1.0) color = vec3(0.08);

  out_color = vec4(color, 1.0);
}

テクスチャの4つの帯:元の画像、グレースケール、セピア、ぼかし

グレースケール。
単純な平均(r+g+b)/3では変に見えます。
人間の目は青より緑にずっと敏感だからです。
標準(Rec. 709)の重みを使うと輝度が得られます:

Y=0.2126r+0.7152g+0.0722b

此れは重みベクトルとの内積(線型代数第4章)で、dotは正にaxbx+ayby+azbzを計算します。
重みの合計は1なので、白は白のままです。
純粋な緑は0.7152(明るい灰色)に、純粋な青は僅か0.0722(殆ど黒)になります。
厳密に言えば、此の重みはテクスチャのsRGB値ではなく線型の色の値の為の物です(違いは第10章で説明しました)が、此の様なエフェクトなら誰も気付きません。

セピア。
出力の各チャンネルが入力の3つのチャンネルの別々の混ぜ方になっていて、其れは正に行列とベクトルの積です(線型代数第6章):

[r′g′b′]=[0.3930.7690.1890.3490.6860.1680.2720.5340.131][rgb]

GLSLのmat3(…)コンストラクタは数値を列毎に受け取るので、コードの最初の3つの数値は1行目ではなく1列目(0.393、0.349、0.272)です。
入れ替えると全然違う色になる、典型的なバグです。
白(1,1,1)なら、各チャンネルは行の合計です。
r′=0.393+0.769+0.189=1.351、g′=1.203、b′=0.937です。
min(…, vec3(1.0))が其れを(1,1,0.937)の暖かいクリーム色に切り詰めるので、白いFが黄色っぽく見えます。

ぼかし。
出力の各ピクセルは、周りのテクセルの重み付き平均です。
重みは釣鐘型の曲線(ガウス分布)に従うので、近いテクセル程大きく数えられます:

w(x,y)=e−x2+y22σ2,結果=∑wc∑w

exp(x)はexで、σ=1.5テクセルなら2σ2=4.5です。
重みの合計で割る事で、明るさが変わりません。
1.0 / vec2(textureSize(tex, 0))はUV単位での1テクセルの大きさなので、vec2(x, y) * texelは丁度1テクセルずつ進みます。
1ピクセル49回のサンプリングは本物のゲームには遅過ぎます。
本物のぼかしは横のパスと縦のパス(7 + 7回)に分け、更にバイリニアフィルターで1回のサンプリングで2テクセルを読みます。

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

  • マテリアルマップ:現代のマテリアルは複数のテクスチャを持ちます。ベースカラー(アルベド)、法線マップ(次の章)、ラフネス、メタルネス、アンビエントオクルージョン等です。エンジンはサンプリング回数を減らす為に、複数の灰色のマップを1つのテクスチャのR、G、B、Aチャンネルに詰める事が良くあります。
  • アトラスとスプライトシート:2Dゲームは多くのスプライトや文字やUIの部品を1つの大きなテクスチャに入れ、UVのオフセットと拡大縮小で1つを選びます。其の為、テクスチャを1つバインドするだけでシーン全体を描けます。ビットマップフォントも同じ仕組みです。
  • テクスチャ配列と3Dテクスチャ:sampler2DArrayは同じ大きさの多くのレイヤー(地形のマテリアル、アニメーションのフレーム)を持ち、sampler3Dはボリューム(霧、カラーグレーディングのテーブル)を持ちます。
  • 圧縮フォーマット:ゲームがGL_RGBA8のまま出荷する事は殆どありません。ブロック圧縮(PCとコンソールではBC1〜BC7、モバイルではASTCとETC2)は4分の1以下の大きさで保存し、GPUがサンプリング中に展開します。
  • sRGBテクスチャ:色のテクスチャは普通sRGBで保存され、GL_SRGB8_ALPHA8として作られるので、サンプラーがライティングの為に線型の値に変換してくれます(第10章)。法線マップの様なデータテクスチャはsRGBにしてはいけません。
  • レンダーターゲット:レンダリングしたフレーム自体もテクスチャです。ブルーム、ぼかし、カラーグレーディング、被写界深度、スクリーンスペースリフレクションは全て、11.9節の様に前のパスをサンプリングするフラグメントシェーダーです。
  • サンプラーオブジェクト:OpenGL 3.3以降、フィルタリングとラップの設定を別のサンプラーオブジェクト(glGenSamplers)に持たせる事が出来るので、1つのテクスチャを違う設定で読めます。VulkanとDirect3Dでは、テクスチャとサンプラーは常に別々です。
  • ストリーミングとミップマップ:大きなオープンワールドでは、遠くのテクスチャは小さいミップレベルだけを読み込み、カメラが近付くと大きいレベルをストリーミングします。

11.11 まとめ

  • テクスチャはGPU上のテクセルの格子です。シェーダーは0〜1のUV座標で読み、テクセルiの中心は(i+0.5)/Wです。
  • CではglGenTextures、glBindTexture、glTexImage2D、glGenerateMipmapで作り、glTexParameteriでフィルタリングとラップを設定し、glActiveTextureとglUniform1iでテクスチャユニットを通してsampler2Dに繋ぎます。
  • texture(sampler, uv)はラップしてフィルタリングしてvec4を返します。先にUVを変える事で、画像を反転、繰り返し、スクロール、歪ませる事が出来ます。
  • ラップモード:GL_REPEAT、GL_MIRRORED_REPEAT、GL_CLAMP_TO_EDGE、GL_CLAMP_TO_BORDER。
  • 拡大:GL_NEARESTはブロックに、GL_LINEARはバイリニア補間(x方向に2回、y方向に1回のmix)になります。
  • ミップマップ無しの縮小はエイリアシングを起こします。ミップマップのメモリは3分の1増えるだけで、GPUはUVの微分からλ=log2ρでレベルを選びます。
  • texelFetch、textureSize、textureLodは正確なテクセル、大きさ、レベルを読みます。dFdxとdFdyは隣のピクセルとの差を返し、一様な制御フローが必要です(textureも同じ)。
  • 画像エフェクトはサンプリングした色の計算に過ぎません。グレースケールは内積、セピアは行列、ぼかしは隣の重み付き和です。

11.12 課題

  1. ★ 今回のテクスチャは幅256テクセルです。テクセル10の中心はuが幾つの所にありますか?u=0.3でGL_NEARESTはどのテクセルを返しますか?u=0.3のバイリニアフィルタリングについて、s、2つのテクセル番号、fxを計算して下さい。
  2. ★ 11.5節の式を使って、u=1.6、u=2.2、u=−0.25の時にGL_MIRRORED_REPEATが読むu′を計算して下さい。其の後、ラップモードのスクリーンショットで答えを確かめて下さい。
  3. ★ 床の上の或るピクセルで、右の隣に移ると3テクセル、上の隣に移ると12テクセル動きます。ρとλは幾つですか?トライリニアフィルタリングはどの2つのレベルを、どんな重みで混ぜますか?
  4. ★★ ゆっくり回転するタイルの床を作って下さい。サンプリングの前に、第7章のmat2の回転でUVを(0.5,0.5)の周りに回し、テクスチャを4回繰り返します。画像が継ぎ目無く繰り返しながら、Fが回る筈です。
  5. ★★ 「モザイク」エフェクトを書いて下さい。floor(uv * n) / nでUVを粗い格子に揃えます(各ブロックの真ん中を読む様に、半マス分足して下さい)。n=16なら、16 × 16の大きなブロックが其々1色で見える筈です。其の後、nをuTimeで4から64迄アニメーションさせて下さい。
  6. ★★ Cの表に4つ目のフィルターモードGL_NEAREST_MIPMAP_NEARESTを追加し、床を見て下さい。レベルが切り替わる所に、床を横切るくっきりした線が見える筈です。何故GL_LINEAR_MIPMAP_LINEARには其の線が無いのか説明して下さい。
  7. ★★★ ガウスの重みが分離可能、つまりg(x)=e−x2/(2σ2)としてw(x,y)=g(x)g(y)である事を示して下さい。其の後、内側のループが1行をg(x)で横にぼかし、外側のループが7つの行の結果をg(y)で組み合わせる様にblurを書き直して下さい。画像は11.9節のぼかしの帯と全く同じに見える筈です。横のパスを保存する2つ目のテクスチャがあれば、此の考え方で1ピクセル49回ではなく14回のサンプリングで済む理由を説明して下さい。

DIFFをダウンロード

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

前新
@@ -176,6 +176,118 @@ GLuint buildProgram(const char *vertexPath, const char *fragmentPath) {
176 return program; 176 return program;
177 } 177 }
178   178  
    179 // テクスチャの大きさ(テクセル)
    180 #define TEX_SIZE 256
    181  
    182 // テスト用のテクスチャをCで作る(256×256、RGBA、1チャンネル8ビット)
    183 // 1行目(y = 0)がテクスチャの一番下(v = 0)になる
    184 GLuint createTestTexture(void) {
    185 unsigned char *pixels = (unsigned char *)malloc(TEX_SIZE * TEX_SIZE * 4);
    186 if (!pixels) {
    187 puts("テクスチャのメモリ確保に失敗しました");
    188 return 0;
    189 }
    190  
    191 for (int y = 0; y < TEX_SIZE; y++) {
    192 for (int x = 0; x < TEX_SIZE; x++) {
    193 // グラデーション:右に行く程赤く、上に行く程緑になる
    194 float r = (float)x / (TEX_SIZE - 1);
    195 float g = (float)y / (TEX_SIZE - 1);
    196 float b = 0.6f;
    197  
    198 // 32×32テクセルのチェッカーボード(暗いマスは暗くする)
    199 if ((x / 32 + y / 32) % 2 == 1) {
    200 r *= 0.35f;
    201 g *= 0.35f;
    202 b *= 0.35f;
    203 }
    204  
    205 // 「F」の文字(上下左右が反転したらすぐ分かる様に)
    206 int fx = x - 80, fy = y - 64;
    207 int inF = (fx >= 0 && fx < 24 && fy >= 0 && fy < 128) // 縦棒
    208 || (fx >= 0 && fx < 96 && fy >= 104 && fy < 128) // 上の横棒
    209 || (fx >= 0 && fx < 72 && fy >= 56 && fy < 80); // 真ん中の横棒
    210  
    211 // 右上の白い輪(フィルタリングの違いが見える様に)
    212 float dx = x + 0.5f - 216.0f, dy = y + 0.5f - 216.0f;
    213 float d = sqrtf(dx * dx + dy * dy);
    214 int inRing = d > 20.0f && d < 24.0f;
    215  
    216 if (inF || inRing) r = g = b = 1.0f;
    217  
    218 unsigned char *p = &pixels[(y * TEX_SIZE + x) * 4];
    219 p[0] = (unsigned char)(r * 255.0f + 0.5f);
    220 p[1] = (unsigned char)(g * 255.0f + 0.5f);
    221 p[2] = (unsigned char)(b * 255.0f + 0.5f);
    222 p[3] = 255;
    223 }
    224 }
    225  
    226 // テクスチャオブジェクトを作り、ピクセルをGPUに送る
    227 GLuint texture;
    228 glGenTextures(1, &texture);
    229 glBindTexture(GL_TEXTURE_2D, texture);
    230 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, TEX_SIZE, TEX_SIZE, 0,
    231 GL_RGBA, GL_UNSIGNED_BYTE, pixels);
    232 free(pixels);
    233  
    234 // ミップマップ(縮小版の画像の列)を自動で作る
    235 glGenerateMipmap(GL_TEXTURE_2D);
    236  
    237 // 範囲外の色(GL_CLAMP_TO_BORDERの時に使う)
    238 float border[] = { 1.0f, 0.0f, 1.0f, 1.0f };
    239 glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border);
    240 return texture;
    241 }
    242  
    243 // フィルタリングの種類(Fキーで切り替え)
    244 typedef struct {
    245 GLint minFilter;
    246 GLint magFilter;
    247 const char *name;
    248 } FilterMode;
    249  
    250 static const FilterMode filterModes[] = {
    251 { GL_NEAREST, GL_NEAREST, "GL_NEAREST" },
    252 { GL_LINEAR, GL_LINEAR, "GL_LINEAR" },
    253 { GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, "GL_LINEAR_MIPMAP_LINEAR(ミップマップ)" },
    254 };
    255  
    256 // ラップモードの種類(Wキーで切り替え)
    257 typedef struct {
    258 GLint mode;
    259 const char *name;
    260 } WrapMode;
    261  
    262 static const WrapMode wrapModes[] = {
    263 { GL_REPEAT, "GL_REPEAT" },
    264 { GL_MIRRORED_REPEAT, "GL_MIRRORED_REPEAT" },
    265 { GL_CLAMP_TO_EDGE, "GL_CLAMP_TO_EDGE" },
    266 { GL_CLAMP_TO_BORDER, "GL_CLAMP_TO_BORDER" },
    267 };
    268  
    269 // 今バインドされているテクスチャにフィルタリングを設定
    270 void setFilter(int mode) {
    271 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filterModes[mode].minFilter);
    272 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filterModes[mode].magFilter);
    273 printf("フィルタリング: %s\n", filterModes[mode].name);
    274 }
    275  
    276 // 今バインドされているテクスチャにラップモードを設定(S = u方向、T = v方向)
    277 void setWrap(int mode) {
    278 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrapModes[mode].mode);
    279 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrapModes[mode].mode);
    280 printf("ラップモード: %s\n", wrapModes[mode].name);
    281 }
    282  
    283 // キーが押された瞬間だけ1を返す(押しっぱなしでは繰り返さない)
    284 int keyPressed(GLFWwindow *window, int key, int *wasDown) {
    285 int down = glfwGetKey(window, key) == GLFW_PRESS;
    286 int pressed = down && !*wasDown;
    287 *wasDown = down;
    288 return pressed;
    289 }
    290  
179 int main(int argc, char **argv) { 291 int main(int argc, char **argv) {
180 (void)argc; 292 (void)argc;
181 (void)argv; 293 (void)argv;
@@ -261,16 +373,35 @@ int main(int argc, char **argv) {
261 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)offset); 373 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)offset);
262 glEnableVertexAttribArray(1); 374 glEnableVertexAttribArray(1);
263   375  
    376 // テスト用のテクスチャを作る(最初はミップマップ付きのGL_LINEARとGL_REPEAT)
    377 GLuint texture = createTestTexture();
    378 int filterMode = 2;
    379 int wrapMode = 0;
    380 setFilter(filterMode);
    381 setWrap(wrapMode);
    382  
264 int reloadWasDown = 0; 383 int reloadWasDown = 0;
    384 int filterWasDown = 0;
    385 int wrapWasDown = 0;
265   386  
266 // メインレンダリングループ 387 // メインレンダリングループ
267 while (!glfwWindowShouldClose(window)) { 388 while (!glfwWindowShouldClose(window)) {
268 // ESCキーで終了 389 // ESCキーで終了
269 if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1); 390 if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1);
270   391  
    392 // Fキーでフィルタリング、Wキーでラップモードを切り替える
    393 glBindTexture(GL_TEXTURE_2D, texture);
    394 if (keyPressed(window, GLFW_KEY_F, &filterWasDown)) {
    395 filterMode = (filterMode + 1) % 3;
    396 setFilter(filterMode);
    397 }
    398 if (keyPressed(window, GLFW_KEY_W, &wrapWasDown)) {
    399 wrapMode = (wrapMode + 1) % 4;
    400 setWrap(wrapMode);
    401 }
    402  
271 // Rキーでシェーダーを再読み込み(失敗したら古いシェーダーのまま) 403 // Rキーでシェーダーを再読み込み(失敗したら古いシェーダーのまま)
272 int reloadDown = glfwGetKey(window, GLFW_KEY_R) == GLFW_PRESS; 404 if (keyPressed(window, GLFW_KEY_R, &reloadWasDown)) {
273 if (reloadDown && !reloadWasDown) {    
274 GLuint newProgram = buildProgram(vertexPath, fragmentPath); 405 GLuint newProgram = buildProgram(vertexPath, fragmentPath);
275 if (newProgram) { 406 if (newProgram) {
276 glDeleteProgram(shaderProgram); 407 glDeleteProgram(shaderProgram);
@@ -280,7 +411,6 @@ int main(int argc, char **argv) {
280 puts("再読み込みに失敗しました。前のシェーダーを使い続けます"); 411 puts("再読み込みに失敗しました。前のシェーダーを使い続けます");
281 } 412 }
282 } 413 }
283 reloadWasDown = reloadDown;    
284   414  
285 // 画面をクリア 415 // 画面をクリア
286 glClearColor(0.0f, 0.0f, 0.0f, 1.0f); 416 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
@@ -302,6 +432,11 @@ int main(int argc, char **argv) {
302 glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"), 432 glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"),
303 (float)mouseX * scale, (float)(windowH - mouseY) * scale); 433 (float)mouseX * scale, (float)(windowH - mouseY) * scale);
304   434  
    435 // テクスチャをユニット0にバインドし、サンプラーにユニットの番号を教える
    436 glActiveTexture(GL_TEXTURE0);
    437 glBindTexture(GL_TEXTURE_2D, texture);
    438 glUniform1i(glGetUniformLocation(shaderProgram, "uTexture"), 0);
    439  
305 // クアッドを描画 440 // クアッドを描画
306 glBindVertexArray(VAO); 441 glBindVertexArray(VAO);
307 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); 442 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
@@ -315,6 +450,7 @@ int main(int argc, char **argv) {
315 glDeleteVertexArrays(1, &VAO); 450 glDeleteVertexArrays(1, &VAO);
316 glDeleteBuffers(1, &VBO); 451 glDeleteBuffers(1, &VBO);
317 glDeleteBuffers(1, &EBO); 452 glDeleteBuffers(1, &EBO);
    453 glDeleteTextures(1, &texture);
318 glDeleteProgram(shaderProgram); 454 glDeleteProgram(shaderProgram);
319 glfwDestroyWindow(window); 455 glfwDestroyWindow(window);
320 glfwTerminate(); 456 glfwTerminate();

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

前新
@@ -3,20 +3,22 @@
3 in vec2 vUv; 3 in vec2 vUv;
4 out vec4 out_color; 4 out vec4 out_color;
5   5  
6 uniform float uTime; // 起動してからの秒数 6 uniform float uTime;
7 uniform vec2 uResolution; // 画面の大きさ(ピクセル) 7 uniform vec2 uResolution;
8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点) 8 uniform sampler2D uTexture;
9   9  
10 void main() { 10 void main() {
11 // ピクセル座標を0〜1に正規化 11 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
12 vec2 uv = gl_FragCoord.xy / uResolution;    
13   12  
14 // 時間で動くグラデーション 13 // 床の上の点:地平線(p.y = 0)に近い程遠い(距離は 1 / -p.y)
15 vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0)); 14 float depth = 1.0 / max(-p.y, 0.001);
    15 vec2 uv = vec2(p.x * depth, depth) * 0.5;
    16 uv.y += uTime * 0.25; // 前に進む
16   17  
17 // マウスの周りに白い円 18 // texture()はifの外で呼ぶ(理由は本文で)
18 float d = length(gl_FragCoord.xy - uMouse); 19 vec3 floorColor = texture(uTexture, uv).rgb;
19 color = mix(color, vec3(1.0), 1.0 - smoothstep(18.0, 20.0, d)); 20 vec3 skyColor = vec3(0.45, 0.6, 0.85);
    21 vec3 color = p.y < 0.0 ? floorColor : skyColor;
20   22  
21 out_color = vec4(color, 1.0); 23 out_color = vec4(color, 1.0);
22 } 24 }


コメント(0)

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