5. シェーダープレイグラウンド

此れまでのテスターでは、新しい事を試す度にCのコードを変えていました。
色を変えるにもユニフォームを追加するにもCを書き直し、シェーダーを直したらプログラムを再起動する必要がありました。
此の章では、テスターをシェーダープレイグラウンドに作り変えます。
画面全体に1つのフラグメントシェーダーを描き、時間・画面の大きさ・マウスの位置を自動で渡し、Rキーを押すと再起動せずにシェーダーを読み込み直します。

第6章から第10章、及び第14章は、全て此のプレイグラウンドのshader/playground.glslだけを書き換えて進めます。
Shadertoyの様なウェブのシェーダーエディターと同じ考え方で、「GLSLを書く→保存→R→結果を見る」を数秒で繰り返せる様にするのが目的です。

5.1 プレイグラウンドの全体像

プレイグラウンドは、第1章でダウンロードしたmain.c(第4章でも変更していない物)を土台にしています。
変更点は次の通りです。

  • シェーダーのコンパイルとリンクをcompileShader()とbuildProgram()と言う関数にまとめ、失敗したら0を返す様にしました。
  • Rキーでシェーダーを読み込み直します。失敗した場合は前のシェーダーを使い続けます。
  • ESCキーで終了します。
  • 毎フレーム、uTime(起動からの秒数)、uResolution(画面のピクセル数)、uMouse(マウスの位置)をユニフォームで渡します。
  • macOSでは、シェーダーの#version 450を自動で#version 410に書き換えます。其の為、.mac.glslファイルはもう必要ありません。
  • フラグメントシェーダーはshader/playground.glsl、頂点シェーダーは今まで通りshader/vertex.glslです。
  • 色をアニメーションさせていたCのコードは削除しました。時間はuTimeでシェーダーに渡すので、アニメーションはシェーダーの中で書けます。

変更の全体は記事の最後のDIFFで確認出来ます。
此処では、新しい部分を順番に見ていきます。

5.2 ビルドと実行

第4章でmain-ambient.cやmain-directional.cに切り替えていた場合は、main.cに戻して下さい。

  • Windows:main.cの「ビルドから除外」を「いいえ」に戻し、main-ambient.cとmain-directional.cを「はい」にします。F5で起動します。
  • macOS:main.cの「Target Membership」にチェックを入れ直し、他の2つは外します。shader/vertex.glslとshader/playground.glslをプロジェクトに追加し、「Build Phases」→「Copy Files」に加えます(プレイグラウンドは.mac.glslではなく此の2つを読みます)。更に、「Product」→「Scheme」→「Edit Scheme」→「Run」→「Options」で「Working Directory」に「Use custom working directory」を選び、プロジェクトのフォルダを指定して下さい。そうしないと、Rキーで読み込まれるのはビルドの時にコピーされた古いファイルで、編集したplayground.glslではありません。
  • Unix:引数無しで./build-unix.shを実行すると、main.cからビルドして起動します。

起動すると、ウィンドウのタイトルが「GLSLシェーダープレイグラウンド」になり、ゆっくり色が変わる背景と、マウスに付いてくる白い円が表示されます。

色が変わるグラデーションの背景と、マウスの位置の白い円

5.3 C側の変更

ウィンドウの大きさを覚えておく

シェーダーに画面の大きさを渡すには、Cのプログラムが其れを知っていなければなりません。
ファイルの先頭近くに2つのグローバル変数を追加し、リサイズのコールバックで更新します:

// ウィンドウの大きさとマウスの位置(シェーダーに渡す)
static int gWidth = 800;
static int gHeight = 600;

// ビューポートのリサイズコールバック
void framebuffer_size_callback(GLFWwindow *window, int width, int height) {
  (void)window;
  gWidth = width;
  gHeight = height;
  glViewport(0, 0, width, height);
}

起動時のビューポートも、決め打ちの800×600ではなく、実際のフレームバッファの大きさから設定します:

  // ビューポートを設定(Retina等では実際のピクセル数がウィンドウの大きさと違う)
  glfwGetFramebufferSize(window, &gWidth, &gHeight);
  glViewport(0, 0, gWidth, gHeight);
  glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

GLFWには2種類の「大きさ」が有ります。

  • ウィンドウの大きさ(glfwGetWindowSize):OSの画面座標の単位。glfwCreateWindow(800, 600, ...)で指定したのは此方です。
  • フレームバッファの大きさ(glfwGetFramebufferSize):実際のピクセル数。OpenGLが描くのは此方です。

普通のモニターでは2つは同じですが、MacのRetinaディスプレイ等の高DPIの画面では、800×600のウィンドウのフレームバッファが1600×1200ピクセルになります。
ビューポートを800×600のままにすると、画面の左下4分の1にしか描かれません。
gl_FragCoord(後で説明します)はフレームバッファのピクセル単位なので、シェーダーに渡すのもフレームバッファの大きさです。

compileShader と buildProgram

今まではmainの中でシェーダーをコンパイルしていましたが、Rキーで読み込み直す為に、同じ処理を何度でも呼べる関数にします:

// シェーダーをコンパイルし、成功したかどうかを返す
GLuint compileShader(GLenum type, const char *source) {
  GLuint shader = glCreateShader(type);
  glShaderSource(shader, 1, &source, NULL);
  glCompileShader(shader);

  GLint success;
  glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
  if (!success) {
    checkShaderCompile(shader);
    glDeleteShader(shader);
    return 0;
  }
  return shader;
}

compileShaderは、成功したらシェーダーオブジェクトを、失敗したらエラーログを表示してから0を返します。
OpenGLのオブジェクト名に0が使われる事は無いので、0を「失敗」の印に使えます。
失敗したシェーダーはglDeleteShaderで削除しておかないと、Rを押す度にリークします。

// 頂点シェーダーとフラグメントシェーダーのファイルからプログラムを作る(失敗したら0)
GLuint buildProgram(const char *vertexPath, const char *fragmentPath) {
  char *vertexSource = readShaderFile(vertexPath);
  char *fragmentSource = readShaderFile(fragmentPath);
  if (!vertexSource || !fragmentSource) {
    free(vertexSource);
    free(fragmentSource);
    return 0;
  }

#ifdef __APPLE__
  // macOSはOpenGL 4.1までなので、「#version 450」を「#version 410」に書き換える
  char *v;
  if ((v = strstr(vertexSource, "#version 450"))) v[10] = '1';
  if ((v = strstr(fragmentSource, "#version 450"))) v[10] = '1';
#endif

  GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
  GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
  free(vertexSource);
  free(fragmentSource);
  if (!vertexShader || !fragmentShader) {
    if (vertexShader) glDeleteShader(vertexShader);
    if (fragmentShader) glDeleteShader(fragmentShader);
    return 0;
  }

  GLuint program = glCreateProgram();
  glAttachShader(program, vertexShader);
  glAttachShader(program, fragmentShader);
  glLinkProgram(program);
  glDeleteShader(vertexShader);
  glDeleteShader(fragmentShader);

  GLint success;
  glGetProgramiv(program, GL_LINK_STATUS, &success);
  if (!success) {
    checkProgramLink(program);
    glDeleteProgram(program);
    return 0;
  }
  return program;
}

buildProgramは、ファイルの読み込み、コンパイル、リンクの何処で失敗しても、確保した物を全て解放してから0を返します。
free(NULL)は何もしない事がC言語で決まっているので、片方だけ読み込めた場合でもfreeを2回呼んで安全です。
strstrを使う為に、ファイルの先頭に#include <string.h>を追加しています。

macOSの#versionの書き換え

macOSのOpenGLは4.1までしか無いので、#version 450のシェーダーはコンパイル出来ません。
第1章から第4章では其の為に.mac.glslと言う別のファイルを用意していましたが、プレイグラウンドでは同じファイルを全てのOSで使える様に、読み込んだ文字列を書き換えます。

strstr(s, "#version 450")は、文字列sの中で"#version 450"が最初に現れる位置へのポインターを返します(見つからなければNULL)。
其処から数えると:

添字 0 1〜7 8 9 10 11
文字 # version 空白 4 5 0

10番目の'5'を'1'に変えると#version 410になります。
ファイルを書き換えるのではなく、メモリの中のコピーだけを変えているので、WindowsやUnixでは元の#version 450のまま使われます。

但し、此れは版数の数字を変えるだけです。
GLSL 4.50にしか無い機能(例えばコンピュートシェーダーや一部の組み込み関数)を使うと、Macではコンパイルエラーになります。
此のシリーズのプレイグラウンドの章では、4.10でも動く機能だけを使います。
又、#version 460の様に別の版数を書くと書き換えられないので、プレイグラウンドのシェーダーは必ず#version 450 coreで始めて下さい。

シェーダーを読み込む

mainの中では、buildProgramを1回呼ぶだけになりました:

  // シェーダーを読み込む
  const char *vertexPath = "shader/vertex.glsl";
  const char *fragmentPath = "shader/playground.glsl";
  GLuint shaderProgram = buildProgram(vertexPath, fragmentPath);
  if (!shaderProgram) {
    puts("シェーダーの読み込みに失敗しました");
    glfwDestroyWindow(window);
    glfwTerminate();
    return -1;
  }

起動時に失敗した場合は、使えるシェーダーが1つも無いので終了します。
頂点データ(フルスクリーンクアッド)とVAOの設定は第1章のmain.cと同じです。

ESCとRキー

メインループの最初でキーボードを調べます:

  int reloadWasDown = 0;

  // メインレンダリングループ
  while (!glfwWindowShouldClose(window)) {
    // ESCキーで終了
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1);

    // Rキーでシェーダーを再読み込み(失敗したら古いシェーダーのまま)
    int reloadDown = glfwGetKey(window, GLFW_KEY_R) == GLFW_PRESS;
    if (reloadDown && !reloadWasDown) {
      GLuint newProgram = buildProgram(vertexPath, fragmentPath);
      if (newProgram) {
        glDeleteProgram(shaderProgram);
        shaderProgram = newProgram;
        puts("シェーダーを再読み込みしました");
      } else {
        puts("再読み込みに失敗しました。前のシェーダーを使い続けます");
      }
    }
    reloadWasDown = reloadDown;

glfwGetKeyは「今キーが押されているか」を返します。
人がRキーを押してから離すまでには、60fpsなら数フレームから十数フレームかかります。
単にif (reloadDown)と書くと、其の間ずっと毎フレームシェーダーを読み込み直してしまいます。
其処で、前のフレームの状態reloadWasDownを覚えておき、「前のフレームでは押されていなくて、今のフレームで押されている」瞬間(押された瞬間)だけ読み込み直します。
此れをエッジ検出と言います。

新しいプログラムの作成に失敗した場合、古いshaderProgramは削除しません。
何故でしょうか?
もし古いプログラムを先に削除してしまうと、描画に使えるプログラムが無くなり、画面が黒くなるか、エラーになります。
シェーダーを書いている時は、セミコロンを1つ忘れる様な失敗は日常茶飯事です。
古いシェーダーのまま動き続ければ、ターミナルのエラーを見ながら直して、もう一度Rを押すだけで済みます。
新しいプログラムが出来た時だけ、古い物を削除して入れ替えます。

例えば、out_color = vec4(color, 1.0);のセミコロンを消してRを押すと、ターミナルには次の様に表示され、画面は前の状態のまま動き続けます:

シェーダーファイルを読み込み中: shader/vertex.glsl
シェーダーファイルを読み込み中: shader/playground.glsl
シェーダーのコンパイルに失敗しました:
0:22(1): error: syntax error, unexpected '}', expecting ',' or ';'

再読み込みに失敗しました。前のシェーダーを使い続けます

0:22(1)は「0番目のソース文字列の22行目、1文字目」と言う意味です。
セミコロンが無い事に気付くのは次の行の}を読んだ時なので、エラーの行番号は実際の間違いの1行後になっています。
エラーメッセージの書式はGPUのドライバーによって違います(此れはMesaの書式で、NVIDIAでは0(22) : error C0000: ...の様になります)。

ユニフォームを渡す

毎フレーム、描画の前に3つのユニフォームを設定します:

    // マウスの位置(ピクセル、左下が原点になる様にyを反転)
    double mouseX, mouseY;
    glfwGetCursorPos(window, &mouseX, &mouseY);
    int windowW, windowH;
    glfwGetWindowSize(window, &windowW, &windowH);
    float scale = windowW > 0 ? (float)gWidth / (float)windowW : 1.0f;

    // ユニフォームを設定
    glUniform1f(glGetUniformLocation(shaderProgram, "uTime"), (float)glfwGetTime());
    glUniform2f(glGetUniformLocation(shaderProgram, "uResolution"), (float)gWidth, (float)gHeight);
    glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"),
                (float)mouseX * scale, (float)(windowH - mouseY) * scale);

  • uTime:glfwGetTime()、GLFWを初期化してからの秒数です。
  • uResolution:フレームバッファの幅と高さ(ピクセル)です。
  • uMouse:マウスの位置をフレームバッファのピクセルに変換した物です。

glfwGetCursorPosは、ウィンドウの左上を原点とし、yが下向きの、ウィンドウの単位の座標を返します。
一方、OpenGLのgl_FragCoordは左下が原点で、yが上向きの、フレームバッファのピクセルです。
其処で2つの変換をします。

  1. yを反転します:y′=Hwindow−y。
  2. ウィンドウの単位からピクセルに変換します:scale=Wframebuffer/Wwindowを掛けます。

左:glfwGetCursorPosの座標、原点が左上でyが下向き。右:uMouseとgl_FragCoordの座標、原点が左下でyが上向き

数字で確かめましょう。
800×600の普通のウィンドウで、カーソルが(560,200)に有る場合、scale=1なので:

uMouse=(560×1,(600−200)×1)=(560,400)

同じウィンドウがRetinaディスプレイに有り、フレームバッファが1600×1200なら、scale=1600/800=2なので:

uMouse=(560×2,(600−200)×2)=(1120,800)

どちらの場合も、シェーダーから見ると「画面の右から30%、上から3分の1」の同じ場所です。
windowW > 0の確認は、ウィンドウが最小化されて大きさが0になった時の0除算を防ぐ為です。

シェーダーが或るユニフォームを使っていない場合、GLSLコンパイラーは其れを削除するので、glGetUniformLocationは−1を返し、其のglUniform*は無視されます(第3章)。
其の為、プレイグラウンドのシェーダーはuTime、uResolution、uMouseの中で必要な物だけを宣言すれば良いのです。

glGetUniformLocationを毎フレーム呼ぶのは少し無駄ですが、Rで読み込み直すとプログラムが変わってロケーションも変わり得るので、此処では分かり易さを優先しています。実際のエンジンでは、プログラムを作った時にロケーションを調べて保存しておきます。

5.4 GLSL側:playground.glsl

プレイグラウンドの最初のシェーダーは次の通りです:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;       // 起動してからの秒数
uniform vec2 uResolution;  // 画面の大きさ(ピクセル)
uniform vec2 uMouse;       // マウスの位置(ピクセル、左下が原点)

void main() {
  // ピクセル座標を0〜1に正規化
  vec2 uv = gl_FragCoord.xy / uResolution;

  // 時間で動くグラデーション
  vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0));

  // マウスの周りに白い円
  float d = length(gl_FragCoord.xy - uMouse);
  color = mix(color, vec3(1.0), 1.0 - smoothstep(18.0, 20.0, d));

  out_color = vec4(color, 1.0);
}

gl_FragCoord

gl_FragCoordはフラグメントシェーダーの組み込みの入力変数(vec4)で、xyは其のフラグメントのフレームバッファの中でのピクセル座標です。
左下のピクセルの中心が(0.5,0.5)、右上のピクセルの中心が(W−0.5,H−0.5)です。
(zは深度、wは1/wclipですが、2Dのプレイグラウンドでは使いません。)

第3章では頂点シェーダーから貰うvUvで「画面の何処か」を知りましたが、gl_FragCoordならデータを何も渡さなくても分かります。
フルスクリーンクアッドでは、gl_FragCoord.xy / uResolutionはvUvと同じ値になります。

時間で動くグラデーション

0.5 + 0.5 * cos(...)は、第3章のCのアニメーションと同じ「−1〜1を0〜1に変換する」形です:

𝐜=0.5+0.5cos(t+(u,v,u)+(0,2,4))

uv.xyxはスウィズル(第2章)で、(u,v,u)のvec3を作ります。
cosにvec3を渡すと、3つの成分其々のコサインを計算します。
R、G、Bに0、2、4ラジアンのずれが有るので、第3章の120°(約2.09ラジアン)のずれと同じ様に、3つのチャンネルの波がずれて色相が回ります。
t=0で左下(u=v=0)を計算すると:

(0.5+0.5cos0,0.5+0.5cos2,0.5+0.5cos4)=(1,0.292,0.173)

オレンジがかった赤です。
第3章ではCで9個のサインを計算しましたが、此処ではシェーダーの1行で、しかもピクセル毎に違う色になります。

マウスの円

length(gl_FragCoord.xy - uMouse)は、此のピクセルからマウスまでの距離(ピクセル)です。
smoothstep(18.0, 20.0, d)は、距離が18以下で0、20以上で1、其の間で滑らかに変わります。
1.0 - で反転すると「円の中で1、外で0」になり、其れをmixの係数にして白を混ぜます。
18〜20の2ピクセルの幅で滑らかに変わるので、円の縁がギザギザになりません(アンチエイリアス。第6章で詳しく扱います)。

5.5 座標の正規化と縦横比

プレイグラウンドで図形を描く時は、殆ど毎回、最初にピクセル座標を扱い易い座標に変換します。
3つの段階を見ていきましょう。

gl_FragCoord.xy(0〜800、0〜600)、uv(0〜1)、p(中心が原点、縦が−1〜1)の3つの座標系

0〜1のuv

gl_FragCoord.xy / uResolutionは、vec2同士の割り算なので成分毎に割ります。
xは幅で、yは高さで割られ、どちらも0〜1になります。
座標を色として表示すると、正しく計算出来ているか一目で分かります:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;

void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  out_color = vec4(uv, 0.0, 1.0); // x → 赤、y → 緑
}

左下が黒、右下が赤、左上が緑、右上が黄色のグラデーション

左下が黒(0,0)、右下が赤(1,0)、左上が緑(0,1)、右上が黄色(1,1)です。
もし上下が逆なら、何処かでyを反転させ過ぎています。

中心を原点にして、縦横比を直す

uvは便利ですが、図形を描くには2つの問題が有ります。

  • 原点が左下の角に有ります。円等は画面の中心を原点にした方が書き易いです。
  • xとyの単位が違います。800×600の画面では、uvの0.1は横80ピクセル、縦60ピクセルです。

試しに、uvを−1〜1に変換し、半径0.5の円を描いてみます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;

void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  vec2 p = uv * 2.0 - 1.0;            // −1〜1(但し横と縦で単位が違う)

  float d = length(p);
  vec3 color = vec3(0.1, 0.1, 0.15);
  if (d < 0.5) color = vec3(1.0, 0.7, 0.2);
  out_color = vec4(color, 1.0);
}

結果は横に伸びた楕円になります(下の画像の左)。
此れを直すのが、プレイグラウンドで最もよく使う1行です:

𝐩=2𝐠𝐥_𝐅𝐫𝐚𝐠𝐂𝐨𝐨𝐫𝐝.𝐱𝐲−𝐮𝐑𝐞𝐬𝐨𝐥𝐮𝐭𝐢𝐨𝐧uResolutiony

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;

void main() {
  // 中心が(0, 0)、縦が−1〜1、横は画面の比率に合わせる
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  float d = length(p);
  vec3 color = vec3(0.1, 0.1, 0.15);
  if (d < 0.5) color = vec3(1.0, 0.7, 0.2);
  out_color = vec4(color, 1.0);
}

分解すると:

  1. 2.0 * gl_FragCoord.xy - uResolution:xは0∼800から−800∼800に、yは0∼600から−600∼600になります。画面の中心が(0,0)です。
  2. / uResolution.y:xもyも同じ数(高さ)で割ります。yは−1∼1に、xは−800/600∼800/600、つまり約−1.333∼1.333になります。

xとyを同じ数で割るので、1単位は縦も横も同じ300ピクセルになり、円は円のままです。
数字で確かめましょう。
左下のピクセルの中心(0.5,0.5)では:

𝐩=(1−800600,1−600600)=(−1.332,−0.998)

ピクセル(600.5,450.5)では:

𝐩=(1201−800600,901−600600)=(0.668,0.502)

左:uv × 2 − 1の楕円。右:縦横比を直した円

此の座標では、ウィンドウの大きさを変えても円は常に画面の高さの4分の1の半径(直径は高さの半分)を保ちます。
第3章で800.0 / 600.0と数字を直接書いた問題が、uResolutionのお陰で解決しました。

5.6 時間を使う

uTimeが有れば、シェーダーの中だけでアニメーションが作れます。
よく使う関数は次の3つです。

  • sin(x)、cos(x):−1〜1の間を、周期2πで滑らかに往復します。sin(uTime * k)の周期は2π/k秒です。
  • fract(x):小数部分、x−⌊x⌋。0〜1を繰り返す「のこぎり波」になります。例えばfract(2.75)=0.75、fract(−0.25)=0.75です。
  • step(edge, x):x<edgeなら0、そうでなければ1。

此れらを使って、流れる縞模様と、鼓動する円を描きます:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform float uTime;
uniform vec2 uResolution;

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

  // 背景:右から左へ流れる縞模様
  float stripe = step(0.5, fract(p.x * 4.0 + uTime));
  vec3 color = mix(vec3(0.10, 0.10, 0.15), vec3(0.15, 0.15, 0.25), stripe);

  // 鼓動する円:半径は0.3を中心に±0.1で揺れる
  float radius = 0.3 + 0.1 * sin(uTime * 3.0);
  float d = length(p);
  float circle = 1.0 - smoothstep(radius - 0.01, radius + 0.01, d);
  color = mix(color, vec3(1.0, 0.4, 0.3), circle);

  out_color = vec4(color, 1.0);
}

  • fract(p.x * 4.0 + uTime)は、xが0.25進む毎に0〜1を1回繰り返します。step(0.5, ...)で其の前半を0、後半を1にすると、幅0.125の縞が交互に並びます。
  • 時間を足しているので、同じ値の場所はpx=(c−t)/4、つまり毎秒0.25単位ずつ左に動きます。
  • 半径は0.3+0.1sin3tで、0.2〜0.4の間を周期2π/3≈2.09秒で揺れます。

左に流れる縞模様の上で鼓動するオレンジの円

uTimeはfloatなので、何時間も起動したままにすると精度が落ちます。32ビットのfloatの有効桁数は約7桁なので、約8200秒(2時間強)を超えると1ミリ秒より細かい違いを表せなくなり、10万秒(約28時間)では約8ミリ秒刻み(60fpsの1フレームの半分程)になって、アニメーションがカクカクし始めます。ゲームでは、時間を一定の周期で巻き戻したり、fract等をCのdoubleで計算してから渡したりします。

5.7 マウスに付いてくる

uMouseはピクセル単位なので、pと同じ変換をすれば同じ座標系で比べられます。
マウスを中心にした「懐中電灯」を描いてみましょう:

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;
uniform vec2 uMouse;

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec2 m = (2.0 * uMouse - uResolution) / uResolution.y; // マウスも同じ座標系に

  // マウスに近い程明るい「懐中電灯」
  float d = length(p - m);
  float light = 1.0 - smoothstep(0.0, 0.8, d);
  vec3 color = vec3(0.9, 0.8, 0.6) * light + vec3(0.03, 0.03, 0.06);

  // マウスの位置に小さな点
  color = mix(color, vec3(1.0, 0.2, 0.2), 1.0 - smoothstep(0.02, 0.025, d));
  out_color = vec4(color, 1.0);
}

p - mは、此のピクセルからマウスへのベクトルで、其の長さが距離です(線型代数シリーズの第3章の2点間の距離)。
距離0でlight = 1、0.8以上で0になり、其の間はsmoothstepで滑らかに暗くなります。
最後に、距離0.02〜0.025の所で赤い点を描いて、マウスの位置を示します。

マウスの位置を中心に明るく光る懐中電灯と赤い点

スクリーンショットはカーソルが(560,200)(ウィンドウ座標)の時の物です。
uMouseは(560,400)なので、𝐦=((1120−800)/600,(800−600)/600)=(0.533,0.333)、画面の中心より右上です。

5.8 値を色にしてデバッグする

シェーダーにはprintfが有りません。
48万個のピクセルで同時に動いているので、1つの値を表示する方法が無いのです。
其の代わり、調べたい値を色にして出力します。
此れはシェーダープログラマーが毎日使う一番大事なデバッグ方法です。

コツは次の通りです。

  • 0〜1の値は其のままvec3(v)(灰色)や1つのチャンネルにします。
  • 負になり得る値は、正を緑、負を赤の様に分けます。
  • −1〜1の値(法線等)はv * 0.5 + 0.5で0〜1にしてから表示します。
  • 範囲外の値は、目立つ色(青やマゼンタ)で強調します。
  • 目盛りの線を引くと、値の大きさが読めます。

#version 450 core

in vec2 vUv;
out vec4 out_color;

uniform vec2 uResolution;

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

  // 調べたい値(此処ではp.x)を色にする:負は赤、正は緑
  float v = p.x;
  vec3 color = vec3(max(-v, 0.0), max(v, 0.0), 0.0);

  // 0.25毎に細い線を引いて、値の目盛りにする
  vec2 g = abs(fract(p * 4.0 + 0.5) - 0.5);
  if (min(g.x, g.y) < 0.02) color += vec3(0.25);

  // 1を超えた所を青で強調する(範囲外の値を見付ける)
  if (abs(v) > 1.0) color.b = 1.0;
  out_color = vec4(color, 1.0);
}

p.xを色にした画面。左が赤、右が緑、中央が黒、両端の1を超えた所に青が加わる

画面の中央で黒(px=0)、左に行く程赤、右に行く程緑になり、|px|>1の両端は青が加わってマゼンタとシアンになっています。
格子の線は0.25毎なので、中央から端まで数えると、左右の端が約1.33である事が読み取れます。

目盛りの線の式abs(fract(p * 4.0 + 0.5) - 0.5)は、「一番近い0.25の倍数までの距離(0.25を1とした単位)」です。
fract(x + 0.5) - 0.5は−0.5〜0.5になり、整数の所で0になるので、其の絶対値が小さい所が線になります。

他にも、次の様な時に色のデバッグが役立ちます。

  • 法線が正しいか:out_color = vec4(normal * 0.5 + 0.5, 1.0);。上を向いた面が黄緑っぽく、右を向いた面がピンクっぽくなります。
  • 値がNaN(非数)になっていないか:if (isnan(v)) color = vec3(1.0, 0.0, 1.0);。normalize(vec3(0.0))やsqrt(-1.0)等で発生し、周りに広がって黒や変な色の点になります。
  • 処理がどの分岐を通ったか:分岐毎に違う色を出力します。

5.9 此の章で使ったGLSLの機能

  • gl_FragCoord:フラグメントのフレームバッファでのピクセル座標(vec4)。左下のピクセルの中心が(0.5,0.5)。
  • fract(x):x−⌊x⌋。0〜1を繰り返します。
  • floor(x):x以下の最大の整数。
  • step(edge, x):x<edgeなら0、そうでなければ1。
  • sin(x)、cos(x):ラジアンの三角関数。ベクトルには成分毎に働きます。
  • smoothstep(e0, e1, x):第3章の滑らかな0〜1の切り替え。
  • length(v):ベクトルの長さ。length(a - b)は2点間の距離です。
  • min(a, b)、max(a, b)、abs(x):最小、最大、絶対値。
  • isnan(x):NaNならtrue。
  • スウィズルuv.xyx:成分を並べ替えたり繰り返したりした新しいベクトル。
  • vec2 / vec2、vec2 / float:成分毎の割り算、全成分を同じ数で割る割り算。

5.10 ゲームやエンジンでの使い方

  • Shadertoy:ウェブで最も有名なシェーダーの遊び場で、iTime、iResolution、iMouseと言うユニフォームが有ります。此の章のプレイグラウンドと全く同じ考え方なので、Shadertoyのシェーダーは名前を変えるだけで大体動きます(但し、ShadertoyのiMouseはクリック位置等の情報も持つvec4です)。
  • ポストプロセス:ゲームのブルーム、色調補正、被写界深度、ビネット等の画面効果は、全てフルスクリーンの四角形(又は三角形)に描くフラグメントシェーダーです。此の章のプレイグラウンドは其の練習になります。
  • ホットリロード:UnityやUnreal Engine、Godotは、シェーダーを保存すると自動で読み込み直し、エラーが有れば前のシェーダーを使い続けます。此の章のRキーと同じ仕組みです。自作エンジンでも、ファイルの更新時刻を監視して自動で読み込み直す機能はとても役に立ちます。
  • デバッグ表示:殆どのエンジンには、法線、UV、深度、ライティングだけ等を色で表示する「ビューモード」が有ります。RenderDocの様なGPUデバッガーでも、此の章の様に値を色で調べます。
  • 高DPI:ウィンドウの大きさとフレームバッファの大きさの違いは、UIがぼやける、クリック位置がずれる、と言うゲームでよくあるバグの原因です。マウスの座標は必ずフレームバッファの単位に変換しましょう。

5.11 まとめ

  • プレイグラウンドは、画面全体にshader/playground.glslを描き、uTime、uResolution、uMouseを毎フレーム渡します。
  • buildProgram()は失敗すると0を返します。Rキーで読み込み直し、失敗したら前のプログラムを使い続けます。
  • キーが押された瞬間だけ反応するには、前のフレームの状態を覚えておきます(エッジ検出)。
  • フレームバッファの大きさ(ピクセル)とウィンドウの大きさは、高DPIの画面では違います。シェーダーに渡すのはフレームバッファの大きさです。
  • マウスの座標はyを反転し、フレームバッファのピクセルに変換します。
  • macOSでは#version 450を410に書き換えるので、同じシェーダーファイルが全てのOSで使えます。
  • gl_FragCoord.xy / uResolutionで0〜1、(2.0 * gl_FragCoord.xy - uResolution) / uResolution.yで中心が原点・縦横比が正しい座標になります。
  • sin、cos、fractとuTimeでアニメーションが作れます。
  • シェーダーのデバッグは、値を色にして出力するのが基本です。

5.12 課題

  1. ★ 1280×720のウィンドウで、ピクセル(960.5,180.5)のuvとpを計算して下さい。又、pのxの範囲(左端と右端)は幾つですか?
  2. ★ fract(3.2)、fract(-1.75)、step(0.5, 0.3)、step(0.5, 0.5)を計算して下さい。
  3. ★★ プレイグラウンドで、画面の中心に半径0.25の白い円を描き、uTimeを使って半径0.5の円周上を5秒で1周させて下さい(ヒント:中心をvec2(cos(a), sin(a)) * 0.5にし、a=2πt/5)。円は楕円にならずに回る筈です。
  4. ★★ マウスの位置で画面を4つに分け、左下を赤、右下を緑、左上を青、右上を白で塗って下さい(ヒント:step(uMouse, gl_FragCoord.xy)はvec2を返します)。マウスを動かすと境界の十字が付いてくる筈です。
  5. ★★ わざと間違い(例えばvec3 color = 1.0;)を書いてRを押し、ターミナルのエラーメッセージを読んで下さい。画面が前のシェーダーのまま動き続ける事を確認してから、直してもう一度Rを押して下さい。
  6. ★★ length(p)を色のデバッグで表示して下さい:0〜1を灰色の濃さにし、1を超えた所を赤くします。中心が黒く、半径1の円の外側が赤い画面になる筈です。
  7. ★★★ main.cを変更して、Rキーを押さなくてもplayground.glslが保存されたら自動で読み込み直す様にして下さい(ヒント:POSIXではstat()のst_mtime、Windowsでは_stat()でファイルの更新時刻を調べ、毎フレームではなく0.5秒毎に確認します)。保存した瞬間にエディターが書きかけのファイルを読まない様にするにはどうすれば良いかも考えて下さい。

DIFFをダウンロード

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

前新
@@ -9,6 +9,7 @@
9 #include <GLFW/glfw3.h> 9 #include <GLFW/glfw3.h>
10 #include <stdio.h> 10 #include <stdio.h>
11 #include <stdlib.h> 11 #include <stdlib.h>
    12 #include <string.h>
12 #include <vector.h> 13 #include <vector.h>
13 #include <math.h> 14 #include <math.h>
14   15  
@@ -103,12 +104,78 @@ void checkProgramLink(GLuint program) {
103 } 104 }
104 } 105 }
105   106  
    107 // ウィンドウの大きさとマウスの位置(シェーダーに渡す)
    108 static int gWidth = 800;
    109 static int gHeight = 600;
    110  
106 // ビューポートのリサイズコールバック 111 // ビューポートのリサイズコールバック
107 void framebuffer_size_callback(GLFWwindow *window, int width, int height) { 112 void framebuffer_size_callback(GLFWwindow *window, int width, int height) {
108 (void)window; 113 (void)window;
    114 gWidth = width;
    115 gHeight = height;
109 glViewport(0, 0, width, height); 116 glViewport(0, 0, width, height);
110 } 117 }
111   118  
    119 // シェーダーをコンパイルし、成功したかどうかを返す
    120 GLuint compileShader(GLenum type, const char *source) {
    121 GLuint shader = glCreateShader(type);
    122 glShaderSource(shader, 1, &source, NULL);
    123 glCompileShader(shader);
    124  
    125 GLint success;
    126 glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    127 if (!success) {
    128 checkShaderCompile(shader);
    129 glDeleteShader(shader);
    130 return 0;
    131 }
    132 return shader;
    133 }
    134  
    135 // 頂点シェーダーとフラグメントシェーダーのファイルからプログラムを作る(失敗したら0)
    136 GLuint buildProgram(const char *vertexPath, const char *fragmentPath) {
    137 char *vertexSource = readShaderFile(vertexPath);
    138 char *fragmentSource = readShaderFile(fragmentPath);
    139 if (!vertexSource || !fragmentSource) {
    140 free(vertexSource);
    141 free(fragmentSource);
    142 return 0;
    143 }
    144  
    145 #ifdef __APPLE__
    146 // macOSはOpenGL 4.1までなので、「#version 450」を「#version 410」に書き換える
    147 char *v;
    148 if ((v = strstr(vertexSource, "#version 450"))) v[10] = '1';
    149 if ((v = strstr(fragmentSource, "#version 450"))) v[10] = '1';
    150 #endif
    151  
    152 GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
    153 GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
    154 free(vertexSource);
    155 free(fragmentSource);
    156 if (!vertexShader || !fragmentShader) {
    157 if (vertexShader) glDeleteShader(vertexShader);
    158 if (fragmentShader) glDeleteShader(fragmentShader);
    159 return 0;
    160 }
    161  
    162 GLuint program = glCreateProgram();
    163 glAttachShader(program, vertexShader);
    164 glAttachShader(program, fragmentShader);
    165 glLinkProgram(program);
    166 glDeleteShader(vertexShader);
    167 glDeleteShader(fragmentShader);
    168  
    169 GLint success;
    170 glGetProgramiv(program, GL_LINK_STATUS, &success);
    171 if (!success) {
    172 checkProgramLink(program);
    173 glDeleteProgram(program);
    174 return 0;
    175 }
    176 return program;
    177 }
    178  
112 int main(int argc, char **argv) { 179 int main(int argc, char **argv) {
113 (void)argc; 180 (void)argc;
114 (void)argv; 181 (void)argv;
@@ -135,7 +202,7 @@ int main(int argc, char **argv) {
135 #endif 202 #endif
136   203  
137 // ウィンドウを作成 204 // ウィンドウを作成
138 GLFWwindow *window = glfwCreateWindow(800, 600, "OpenGLシェーダーテスト", NULL, NULL); 205 GLFWwindow *window = glfwCreateWindow(800, 600, "GLSLシェーダープレイグラウンド", NULL, NULL);
139 if (!window) { 206 if (!window) {
140 puts("GLFWウィンドウの作成に失敗しました"); 207 puts("GLFWウィンドウの作成に失敗しました");
141 glfwTerminate(); 208 glfwTerminate();
@@ -151,50 +218,22 @@ int main(int argc, char **argv) {
151 return -1; 218 return -1;
152 } 219 }
153   220  
154 // ビューポートを設定 221 // ビューポートを設定(Retina等では実際のピクセル数がウィンドウの大きさと違う)
155 glViewport(0, 0, 800, 600); 222 glfwGetFramebufferSize(window, &gWidth, &gHeight);
    223 glViewport(0, 0, gWidth, gHeight);
156 glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); 224 glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
157   225  
158 // シェーダーファイルを読み込む 226 // シェーダーを読み込む
159 #ifdef __APPLE__ 227 const char *vertexPath = "shader/vertex.glsl";
160 char *vertexShaderSource = readShaderFile("shader/vertex.mac.glsl"); 228 const char *fragmentPath = "shader/playground.glsl";
161 char *fragmentShaderSource = readShaderFile("shader/fragment.mac.glsl"); 229 GLuint shaderProgram = buildProgram(vertexPath, fragmentPath);
162 #else 230 if (!shaderProgram) {
163 char *vertexShaderSource = readShaderFile("shader/vertex.glsl"); 231 puts("シェーダーの読み込みに失敗しました");
164 char *fragmentShaderSource = readShaderFile("shader/fragment.glsl");    
165 #endif    
166 if (!vertexShaderSource || !fragmentShaderSource) {    
167 puts("シェーダーファイルの読み込みに失敗しました");    
168 if (vertexShaderSource) free(vertexShaderSource);    
169 if (fragmentShaderSource) free(fragmentShaderSource);    
170 glfwDestroyWindow(window); 232 glfwDestroyWindow(window);
171 glfwTerminate(); 233 glfwTerminate();
172 return -1; 234 return -1;
173 } 235 }
174   236  
175 // シェーダーをコンパイル    
176 GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);    
177 glShaderSource(vertexShader, 1, (const char **)&vertexShaderSource, NULL);    
178 glCompileShader(vertexShader);    
179 checkShaderCompile(vertexShader);    
180      
181 GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);    
182 glShaderSource(fragmentShader, 1, (const char **)&fragmentShaderSource, NULL);    
183 glCompileShader(fragmentShader);    
184 checkShaderCompile(fragmentShader);    
185      
186 // シェーダープログラムをリンク    
187 GLuint shaderProgram = glCreateProgram();    
188 glAttachShader(shaderProgram, vertexShader);    
189 glAttachShader(shaderProgram, fragmentShader);    
190 glLinkProgram(shaderProgram);    
191 checkProgramLink(shaderProgram);    
192      
193 glDeleteShader(vertexShader);    
194 glDeleteShader(fragmentShader);    
195 free(vertexShaderSource);    
196 free(fragmentShaderSource);    
197      
198 // 頂点データ(フルスクリーンクアッド)を設定 237 // 頂点データ(フルスクリーンクアッド)を設定
199 float vertices[] = { 238 float vertices[] = {
200 -1.0f, -1.0f, 0.0f, 0.0f, // 左下(位置、UV) 239 -1.0f, -1.0f, 0.0f, 0.0f, // 左下(位置、UV)
@@ -222,8 +261,27 @@ int main(int argc, char **argv) {
222 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)offset); 261 glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)offset);
223 glEnableVertexAttribArray(1); 262 glEnableVertexAttribArray(1);
224   263  
    264 int reloadWasDown = 0;
    265  
225 // メインレンダリングループ 266 // メインレンダリングループ
226 while (!glfwWindowShouldClose(window)) { 267 while (!glfwWindowShouldClose(window)) {
    268 // ESCキーで終了
    269 if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, 1);
    270  
    271 // Rキーでシェーダーを再読み込み(失敗したら古いシェーダーのまま)
    272 int reloadDown = glfwGetKey(window, GLFW_KEY_R) == GLFW_PRESS;
    273 if (reloadDown && !reloadWasDown) {
    274 GLuint newProgram = buildProgram(vertexPath, fragmentPath);
    275 if (newProgram) {
    276 glDeleteProgram(shaderProgram);
    277 shaderProgram = newProgram;
    278 puts("シェーダーを再読み込みしました");
    279 } else {
    280 puts("再読み込みに失敗しました。前のシェーダーを使い続けます");
    281 }
    282 }
    283 reloadWasDown = reloadDown;
    284  
227 // 画面をクリア 285 // 画面をクリア
228 glClearColor(0.0f, 0.0f, 0.0f, 1.0f); 286 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
229 glClear(GL_COLOR_BUFFER_BIT); 287 glClear(GL_COLOR_BUFFER_BIT);
@@ -231,33 +289,18 @@ int main(int argc, char **argv) {
231 // シェーダープログラムを使用 289 // シェーダープログラムを使用
232 glUseProgram(shaderProgram); 290 glUseProgram(shaderProgram);
233   291  
234 // 時間に基づいて色をアニメーション 292 // マウスの位置(ピクセル、左下が原点になる様にyを反転)
235 float time = (float)glfwGetTime(); 293 double mouseX, mouseY;
236 float speed = 1.0f; // アニメーションの速度 294 glfwGetCursorPos(window, &mouseX, &mouseY);
237 float phase = 2.0f * 3.14159f / 3.0f; // // 120度の位相差(3色用) 295 int windowW, windowH;
238   296 glfwGetWindowSize(window, &windowW, &windowH);
239 // uColorA: 赤 -> 緑 -> 青 297 float scale = windowW > 0 ? (float)gWidth / (float)windowW : 1.0f;
240 Vector3 colorA;    
241 colorA.r = sin(time * speed + 0.0f) * 0.5f + 0.5f; // 0.0 to 1.0    
242 colorA.g = sin(time * speed + phase) * 0.5f + 0.5f;    
243 colorA.b = sin(time * speed + 2.0f * phase) * 0.5f + 0.5f;    
244      
245 // uColorB: 緑 -> 青 -> 赤    
246 Vector3 colorB;    
247 colorB.r = sin(time * speed + 2.0f * phase) * 0.5f + 0.5f;    
248 colorB.g = sin(time * speed + 0.0f) * 0.5f + 0.5f;    
249 colorB.b = sin(time * speed + phase) * 0.5f + 0.5f;    
250      
251 // uColorC: 青 -> 赤 -> 緑    
252 Vector3 colorC;    
253 colorC.r = sin(time * speed + phase) * 0.5f + 0.5f;    
254 colorC.g = sin(time * speed + 2.0f * phase) * 0.5f + 0.5f;    
255 colorC.b = sin(time * speed + 0.0f) * 0.5f + 0.5f;    
256   298  
257 // ユニフォームを設定 299 // ユニフォームを設定
258 glUniform3f(glGetUniformLocation(shaderProgram, "uColorA"), colorA.r, colorA.g, colorA.b); // 赤 300 glUniform1f(glGetUniformLocation(shaderProgram, "uTime"), (float)glfwGetTime());
259 glUniform3f(glGetUniformLocation(shaderProgram, "uColorB"), colorB.r, colorB.g, colorB.b); // 緑 301 glUniform2f(glGetUniformLocation(shaderProgram, "uResolution"), (float)gWidth, (float)gHeight);
260 glUniform3f(glGetUniformLocation(shaderProgram, "uColorC"), colorC.r, colorC.g, colorC.b); // 青 302 glUniform2f(glGetUniformLocation(shaderProgram, "uMouse"),
    303 (float)mouseX * scale, (float)(windowH - mouseY) * scale);
261   304  
262 // クアッドを描画 305 // クアッドを描画
263 glBindVertexArray(VAO); 306 glBindVertexArray(VAO);

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

前新
@@ -0,0 +1,22 @@
    1 #version 450 core
    2  
    3 in vec2 vUv;
    4 out vec4 out_color;
    5  
    6 uniform float uTime; // 起動してからの秒数
    7 uniform vec2 uResolution; // 画面の大きさ(ピクセル)
    8 uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点)
    9  
    10 void main() {
    11 // ピクセル座標を0〜1に正規化
    12 vec2 uv = gl_FragCoord.xy / uResolution;
    13  
    14 // 時間で動くグラデーション
    15 vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0));
    16  
    17 // マウスの周りに白い円
    18 float d = length(gl_FragCoord.xy - uMouse);
    19 color = mix(color, vec3(1.0), 1.0 - smoothstep(18.0, 20.0, d));
    20  
    21 out_color = vec4(color, 1.0);
    22 }


コメント(0)

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