第1部 WebGL2 の基本 — 第6章

全画面シェーダー環境を作る

第3章から第5章まで、レッスンの main.tsは毎回ほぼ同じコードで始まっていました。compileShaderlinkProgram、描画バッファのサイズ合わせ、そして毎フレームの描画ループです。 3 回書いたので、中身はもう手が覚えているはずです。この章では、その繰り返し部分を初めてsrc/lib/の共通モジュールへ移し、フラグメントシェーダーを 1 枚渡すだけで全画面が動き出す環境を作ります。Shadertoy というサイトをご存じなら、あれの最小版を自作する章です。

three.js で言えば、プロジェクトのたびに書いていたWebGLRendererの初期化・リサイズ処理・アニメーションループを、自分専用の小さなヘルパーにまとめる作業に相当します。

この章の成果物。時間でゆっくり揺れるグラデーションで、canvas の上でポインタを動かすと、その周りが少し明るくなります。描いているのはfirst.fragという 30 行ほどのフラグメントシェーダーだけで、残りの段取りはすべてこの章で作る共通モジュールが 引き受けています。第7章からは、このファイルに相当する 1 枚だけを差し替えて進みます。

この章で学ぶこと:

1. なぜ、いま共通化するのか — 抽象化のポリシー

このサイトは、three.js という優れた抽象化をあえて使わずに、内側を理解しに来ています。だから共通化・抽象化には慎重で、 ルールを 1 つだけ置いています:読者がまだ中身を知らないコードは、隠さない。 便利さのために未習の処理をヘルパーに閉じ込めると、three.js を使っているのと変わらなくなるからです。

逆に言えば、すでに 3 回自分の手で書いたコードを隠しても、失われるものはありません。むしろ次の第2部(第7〜10章)では フラグメントシェーダーだけを差し替えて数学と表現の実験を繰り返したいので、実験のたびに同じ段取りを まるごと書き写すのは学習の妨げになります。そこでこの章では、次の線引きで「すでに見たことのあるコード」だけsrc/lib/ に移します。

隠すもの(src/lib/ へ移す)初出の章
コンテキスト取得・dpr を掛けた解像度合わせ第2章
シェーダーのコンパイル・リンクとエラーチェック第3章
rAF ループ・ループ先頭のリサイズチェック・uniform の毎フレーム更新・マウス座標の変換第4章

一方で隠さないものは、フラグメントシェーダー本体です。これはまさに「これから学ぶもの」であり、 第2部以降の主役なので、常に読者(あなた)が書きます。

この章で作るのは次の 3 ファイルです。

2. 全画面三角形のトリック

「全画面をフラグメントシェーダーで塗る」ためには、まず全画面を覆う図形をラスタライザに渡す必要があります。 素直に考えるとクリップ空間 (-1〜+1) ちょうどの四角形、つまり三角形 2 枚ですが、定番はもっと単純で、画面より大きな三角形を 1 枚置きます。頂点は (-1, -1)・(3, -1)・(-1, 3) の 3 つです。

画面クリップ空間 -1〜+1(+1, +1)0: (-1, -1)1: (3, -1)2: (-1, 3)正方形の外側はクリッピングで捨てられる全画面三角形画面をはみ出して覆う
全画面三角形。数字は gl_VertexID(次節)。斜辺はちょうど画面の右上の角 (+1, +1) を通り、正方形全体が三角形に覆われます。

第3章の「壊してみる」で確かめたとおり、クリップ空間の -1〜+1 をはみ出した部分はクリッピングで捨てられます。だからこの三角形を描くと、画面の全ピクセルが ちょうど 1 回ずつフラグメントシェーダーにかけられます。はみ出した部分の計算が無駄になりそうに見えますが、 捨てられた領域にはそもそもフラグメントが生成されないため、余計な塗りは発生しません。

四角形(三角形 2 枚)ではなく 1 枚にする理由は 2 つあります。

3. gl_VertexID で頂点データを消す

たった 3 頂点のために VBO を作って VAO を配線してもよいのですが、ここではさらに一歩進めて、頂点データそのものをなくします。頂点シェーダーには、いま処理している頂点が何番目かを示す組み込み変数gl_VertexID(int型。第5章で登場)があります。頂点の位置が「0 番なら (-1, -1)、1 番なら (3, -1)、2 番なら (-1, 3)」と番号だけで決まるのなら、バッファから座標を 読む必要はなく、シェーダー内で計算してしまえばよいわけです。

src/lib/fullscreen.vert
#version 300 es

// 共通モジュール: 全画面三角形の頂点シェーダー
//
// 頂点バッファ(VBO)も VAO の配線も使わない。in 変数がひとつもない代わりに、
// 「いま何番目の頂点を処理しているか」が入る組み込み変数 gl_VertexID(第5章)から
// クリップ空間の座標を直接計算する。
//
// drawArrays(gl.TRIANGLES, 0, 3) で呼ばれると、3 頂点は次の位置になる:
//   gl_VertexID = 0 → (-1, -1)  画面の左下の角
//   gl_VertexID = 1 → ( 3, -1)  右下のはるか外
//   gl_VertexID = 2 → (-1,  3)  左上のはるか外
// この三角形はクリップ空間の正方形 (-1〜+1) を完全に覆い、
// はみ出した部分はクリッピングで捨てられるため、結果として画面全体が塗られる。

void main() {
  float x = gl_VertexID == 1 ? 3.0 : -1.0;
  float y = gl_VertexID == 2 ? 3.0 : -1.0;
  gl_Position = vec4(x, y, 0.0, 1.0);
}

in変数が 1 つもないことに注目してください。x は「1 番のときだけ 3.0、それ以外は -1.0」、y は「2 番のときだけ 3.0、それ以外は -1.0」— この 2 つの三項演算子だけで、先ほどの図の 3 頂点が出てきます。なお、ウェブ上のコードではビット演算で同じ 3 点を作る書き方((gl_VertexID << 1) & 2など)もよく見かけますが、やっていることは同じです。

CPU 側はどうなるかというと、頂点まわりの準備が丸ごと消えます。WebGL2 では、頂点属性を 1 つも有効化していなくてもdrawArraysは合法です。第3章で見たとおり、属性・VBO・VAO はすべて「頂点シェーダーのin変数へデータを配線する」ための仕組みなので、in変数がないシェーダーには配線するものが何もありません。drawArrays(gl.TRIANGLES, 0, 3)の「3 頂点」という数だけを頼りに、GPU は頂点シェーダーを 3 回呼び、そのそれぞれにgl_VertexIDとして 0・1・2 を渡してくれます。

4. ハーネスの実装 — fullscreen-shader.ts

本体のハーネスは、関数 1 つです。canvas とフラグメントシェーダーのソース文字列を渡すと描画ループが始まり、返り値のstop()で止められます。

使い方(イメージ)
import { startFullscreenShader } from '../../lib/fullscreen-shader';

// canvas とフラグメントシェーダーのソースを渡すとループが始まる。
// オプションは省略可(maxDpr の既定は 2)。返り値の stop() でループを止められる
const { stop } = startFullscreenShader(canvas, fragmentSource, { maxDpr: 2 });

中身を順に見ていきますが、新しい API は 1 つも出てきません。すべて第2〜4章で 自分の手で書いたコードの再構成です。まず入口では、getContext('webgl2')で コンテキストを取得し(第2章)、fullscreen.vertと渡されたフラグメントシェーダーをsrc/lib/shader.tsに移したcompileShader/linkProgramでプログラムにします(第3章)。続いて、uniform のロケーションを先に取得しておきます(第4章)。

src/lib/fullscreen-shader.ts(抜粋)
// uniform ロケーションは毎フレーム引かず、一度だけ取得してキャッシュする。
// フラグメントシェーダー側で宣言していない(または未使用で最適化により消えた)
// uniform のロケーションは null になるが、null を渡した uniform* 呼び出しは
// 黙って無視される(第4章)。だから 3 つのうち必要なものだけ使うシェーダーでも動く。
const timeLocation = gl.getUniformLocation(program, 'u_time');
const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
const mouseLocation = gl.getUniformLocation(program, 'u_mouse');

コメントに書いたとおり、ここでロケーションがnullになっても throw しません。nullを渡されたuniform1fなどが黙って何もしない仕様(第4章)のおかげで、「u_timeだけ使うシェーダー」も「3 つとも使わないシェーダー」も、同じハーネスでそのまま動きます。

マウスはpointermoveを購読して、DOM の座標(左上原点・CSS ピクセル)から描画バッファのピクセル・左下原点へ変換して保持します(この座標系を選ぶ理由は次節)。

src/lib/fullscreen-shader.ts(抜粋)
function onPointerMove(event: PointerEvent): void {
  const rect = canvas.getBoundingClientRect();
  // DOM 座標(左上原点・CSS ピクセル)→ 左下原点・描画バッファのピクセルへ変換。
  // rect はボーダーを含む外形なので、clientLeft/Top(ボーダー幅)を引いて
  // 描画領域内の位置にし、ボーダーを除いたサイズ clientWidth/Height で換算する。
  // canvas.width / canvas.clientWidth が「CSS 1px あたりの描画バッファのピクセル数」(≒ dpr)
  const x = event.clientX - rect.left - canvas.clientLeft;
  const y = event.clientY - rect.top - canvas.clientTop;
  const scaleX = canvas.width / canvas.clientWidth;
  const scaleY = canvas.height / canvas.clientHeight;
  mouseX = x * scaleX;
  mouseY = (canvas.clientHeight - y) * scaleY;
  pointerActive = true;
}
canvas.addEventListener('pointermove', onPointerMove);

y 座標はcanvas.clientHeight - yで上下を反転してから倍率を掛けています。DOM は左上原点・y 下向き、WebGL のピクセル座標は左下原点・y 上向きという、第2章のviewport/scissorで出てきた向きの違いがここでも効いています。

そして描画ループです。第4章で確立した「ループ先頭でリサイズチェック」方式そのままです。

src/lib/fullscreen-shader.ts(抜粋)
function render(timestamp: DOMHighResTimeStamp): void {
  resizeIfNeeded();

  const width = gl.drawingBufferWidth;
  const height = gl.drawingBufferHeight;
  gl.viewport(0, 0, width, height);

  // 標準 uniform を更新する。時間は rAF の timestamp(ミリ秒)を秒に直したもの
  gl.uniform1f(timeLocation, timestamp * 0.001);
  gl.uniform2f(resolutionLocation, width, height);
  if (pointerActive) {
    gl.uniform2f(mouseLocation, mouseX, mouseY);
  } else {
    gl.uniform2f(mouseLocation, width / 2, height / 2);
  }

  // VAO も VBO もバインドしないまま「3 頂点で三角形 1 枚」を描く。
  // 頂点データは使わず、頂点シェーダーが gl_VertexID から座標を作る。
  // 三角形が毎フレーム全ピクセルを塗り直すので、clear も不要
  gl.drawArrays(gl.TRIANGLES, 0, 3);

  rafId = requestAnimationFrame(render);
}

最後に、返り値のstop()cancelAnimationFrameでループを止め、removeEventListenerでポインタの購読も解除します。ページ遷移などで canvas が不要になったときの後始末用です(プログラムやコンテキストなど GPU リソース自体の解放は、第35章でまとめて扱います)。

5. uniform の標準セット

ハーネスが毎フレーム更新する uniform は次の 3 つで、第2部以降のすべての章がこの名前を前提にします。

uniform内容
u_timefloat経過時間()。rAF の timestamp × 0.001
u_resolutionvec2描画バッファのサイズ(ピクセル)。CSS サイズではない
u_mousevec2ポインタ位置。描画バッファのピクセル・左下原点(=gl_FragCoordと同じ座標系)。動かすまでは画面中央

u_mouseの座標系をわざわざ「左下原点のピクセル」にしているのは、フラグメントシェーダーの組み込み変数gl_FragCoordに合わせるためです。gl_FragCoord.xyには、いま塗っているピクセルの位置が左下原点のピクセル単位で入っています。座標系が同じなら、 デモのfirst.fragがやっているようにlength(gl_FragCoord.xy - u_mouse)と書くだけで「ポインタからの距離」が出せて、変換が一切要りません。

第4章ではマウス座標をクリップ空間 (-1〜+1) に変換して渡しました。あちらは「頂点を動かす」用途だったので、頂点の座標系であるクリップ空間が 便利だったのです。全画面シェーダーでは計算の主役がフラグメントシェーダーとgl_FragCoordに移るので、そちらに合わせるほうが便利、という整理です。

6. 使う側はここまで薄くなる

共通化の成果を見てみましょう。この章のデモを動かしている main.ts の全文です。

src/lessons/06-fullscreen-shader/main.ts
// 第6章: 全画面シェーダー環境を作る
// 共通化の成果として、使う側はここまで薄くなる。
// canvas とフラグメントシェーダーを渡すだけで、コンパイル・リンク・リサイズ・
// uniform 更新・描画ループはすべてハーネス(src/lib/fullscreen-shader.ts)がやる。

import { startFullscreenShader } from '../../lib/fullscreen-shader';
import fragmentSource from './first.frag?raw';

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
if (!canvas) {
  throw new Error('canvas 要素 #demo が見つかりません');
}

startFullscreenShader(canvas, fragmentSource);

canvas を取得して、フラグメントシェーダーと一緒にハーネスへ渡す — それだけです。第3章のmain.tsにあった段取りがすべて消え、実質 2 文になりました。そして消えた行の中に、あなたがまだ理解していないものは 1 行もありません。 第7章以降のレッスンの main.ts はすべてこの形になり、書くのは.fragファイルだけになります。

肝心のフラグメントシェーダー側は、標準セットを宣言して使うだけです。

src/lessons/06-fullscreen-shader/first.frag
#version 300 es

// 第6章のデモ: ハーネスの標準 uniform を 3 つとも使う最小のフラグメントシェーダー。
// 使っている関数は mix / sin / length / min だけ。
// 本格的な 2D 表現の数学(UV・アスペクト比・形)は第7章から。

precision highp float;

// ハーネス(src/lib/fullscreen-shader.ts)が毎フレーム更新してくれる標準セット
uniform float u_time; // 経過時間(秒)
uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)
uniform vec2 u_mouse; // ポインタ位置(ピクセル・左下原点 = gl_FragCoord と同じ)

out vec4 fragColor;

void main() {
  // このピクセルの位置を 0〜1 に正規化する(いわゆる UV 座標。詳しくは第7章)
  vec2 uv = gl_FragCoord.xy / u_resolution;

  // 2 色を uv.x の割合で混ぜ、その割合を時間でゆっくり揺らす。
  // mix の第 3 引数は clamp されない(0〜1 の外では外挿になる)ため、0〜1 に収めておく
  vec3 colorA = vec3(0.10, 0.14, 0.35); // 深い青
  vec3 colorB = vec3(0.90, 0.55, 0.25); // オレンジ
  float t = clamp(uv.x + 0.15 * sin(u_time * 0.8 + uv.y * 4.0), 0.0, 1.0);
  vec3 color = mix(colorA, colorB, t);

  // ポインタに近いピクセルほど少し明るくする。
  // u_mouse は gl_FragCoord と同じ座標系なので、変換なしでそのまま距離が取れる
  float dist = length(gl_FragCoord.xy - u_mouse);
  float glow = 1.0 - min(dist / (0.6 * u_resolution.y), 1.0);
  color += 0.2 * glow;

  fragColor = vec4(color, 1.0);
}

gl_FragCoord.xy / u_resolutionでピクセル座標を 0〜1 の範囲に正規化し(この値の性質やアスペクト比の補正は第7章でじっくり扱います)、 2 色をmixで混ぜ、混ぜる割合をsin(u_time ...)で揺らし、u_mouseへの距離で少し明るくする。使っている道具は第5章までにすべて出てきたものです。

7. Shadertoy 読み替え表

この形の環境を作ったことで、世界最大のフラグメントシェーダー作品集である Shadertoy(shadertoy.com)のコードが、手元で読める・移植できるようになります。ただし Shadertoy のエディタに書くコードは完全な GLSL ES 3.00 のファイルではなく、#version行・precision・uniform 宣言・main関数などをサイト側が自動で補ってからコンパイルする「本文だけ」の形式です。コピーしてそのまま動く 互換性はなく、次の読み替えが必要です。

Shadertoyこのハーネス
void mainImage(out vec4 fragColor, in vec2 fragCoord)void main()を書き、out vec4 fragColor;を自分で宣言する。fragCoordの代わりはgl_FragCoord.xy
iTime(float・秒)u_time(同じく秒)
iResolution(vec3)u_resolution(vec2)。iResolution.xyと書かれていたらu_resolutionにそのまま置き換えられる。z 成分(ピクセルアスペクト比)は持たない
iMouse(vec4。zw にクリック位置)u_mouse(vec2)。現在位置のみで、クリック位置や押下状態は持たない。また Shadertoy のiMouse.xyはボタン押下中だけ更新される(とされる)のに対し、こちらは常時更新なので、 ドラッグ操作前提の作品は挙動が変わる点に注意
iChannel0〜3(テクスチャや前フレーム)まだない。テクスチャは第16章、前フレーム参照(フィードバック)は第29章
#version / precision の宣言自分で書く(1 行目に #version 300 es、続けて precision)

まとめると、簡単な作品なら「mainImagemainに改名して引数を消し、fragCoordgl_FragCoord.xyiTimeu_timeiResolution.xyu_resolutionと置換し、冒頭の宣言一式を足す」だけで動きます。iChannel系が出てきたら、まだ移植できません(第16章以降のお楽しみです)。

コード全文

この章で作ったファイルの全文です。src/lib/の 3 つは今後ずっと使い続ける共通モジュールです。デモ側のmain.tsは「6. 使う側はここまで薄くなる」に掲載したものがすべてなので、ここでは再掲しません。

src/lib/shader.ts(第3章から移動)
// 共通モジュール: シェーダーのコンパイルとプログラムのリンク
//
// 実装は第3章 (src/lessons/03-first-triangle/main.ts) で書いたローカル関数を
// そのまま移動したもので、中身は変えていない。
// コンパイル・リンクは失敗しても JavaScript の例外が発生しないため、
// 必ず状態をチェックし、失敗時はログを添えて throw する(第3章)。

export function compileShader(
  gl: WebGL2RenderingContext,
  type: GLenum,
  source: string,
): WebGLShader {
  const shader = gl.createShader(type);
  if (!shader) {
    throw new Error('シェーダーオブジェクトを作成できませんでした');
  }
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error(`シェーダーのコンパイルに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  return shader;
}

export function linkProgram(
  gl: WebGL2RenderingContext,
  vertexShader: WebGLShader,
  fragmentShader: WebGLShader,
): WebGLProgram {
  const program = gl.createProgram();
  if (!program) {
    throw new Error('プログラムオブジェクトを作成できませんでした');
  }
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    const log = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error(`プログラムのリンクに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  // リンクが済めばシェーダーオブジェクト単体は不要になる
  gl.deleteShader(vertexShader);
  gl.deleteShader(fragmentShader);
  return program;
}
src/lib/fullscreen.vert
#version 300 es

// 共通モジュール: 全画面三角形の頂点シェーダー
//
// 頂点バッファ(VBO)も VAO の配線も使わない。in 変数がひとつもない代わりに、
// 「いま何番目の頂点を処理しているか」が入る組み込み変数 gl_VertexID(第5章)から
// クリップ空間の座標を直接計算する。
//
// drawArrays(gl.TRIANGLES, 0, 3) で呼ばれると、3 頂点は次の位置になる:
//   gl_VertexID = 0 → (-1, -1)  画面の左下の角
//   gl_VertexID = 1 → ( 3, -1)  右下のはるか外
//   gl_VertexID = 2 → (-1,  3)  左上のはるか外
// この三角形はクリップ空間の正方形 (-1〜+1) を完全に覆い、
// はみ出した部分はクリッピングで捨てられるため、結果として画面全体が塗られる。

void main() {
  float x = gl_VertexID == 1 ? 3.0 : -1.0;
  float y = gl_VertexID == 2 ? 3.0 : -1.0;
  gl_Position = vec4(x, y, 0.0, 1.0);
}
src/lib/fullscreen-shader.ts
// 共通モジュール: 全画面シェーダーのハーネス
//
// canvas とフラグメントシェーダーのソースを渡すと、
// 「毎フレーム、画面全体をそのシェーダーで塗る」ループを開始する。
// 中身はすべて第2〜4章で書いてきたコードの再構成で、新しい API は登場しない。
//   - コンテキスト取得と devicePixelRatio(第2章)
//   - コンパイル・リンク(第3章 → lib/shader.ts)
//   - rAF ループ・ループ先頭のリサイズチェック・uniform 更新・マウス(第4章)
// 頂点まわり(VBO / VAO)だけは「何も作らない」— 理由は第6章本文を参照。

import vertexSource from './fullscreen.vert?raw';
import { compileShader, linkProgram } from './shader';

export interface FullscreenShaderOptions {
  /** devicePixelRatio の上限(既定 2)。負荷を抑えたいときは 1 に下げる */
  maxDpr?: number;
}

export interface FullscreenShaderHandle {
  /** 描画ループを止め、イベントリスナーを解除する */
  stop: () => void;
}

export function startFullscreenShader(
  canvas: HTMLCanvasElement,
  fragmentSource: string,
  options: FullscreenShaderOptions = {},
): FullscreenShaderHandle {
  const maxDpr = options.maxDpr ?? 2;

  const maybeGl = canvas.getContext('webgl2');
  if (!maybeGl) {
    throw new Error('このブラウザは WebGL2 に対応していません');
  }
  // null を除外した型で持ち直す。こうしておくと、下で定義する描画ループなどの
  // 内側の関数からも「null かもしれない」チェックなしで gl を使える
  const gl = maybeGl;

  // 全画面三角形の頂点シェーダー + 渡されたフラグメントシェーダーでプログラムを作る
  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  // uniform ロケーションは毎フレーム引かず、一度だけ取得してキャッシュする。
  // フラグメントシェーダー側で宣言していない(または未使用で最適化により消えた)
  // uniform のロケーションは null になるが、null を渡した uniform* 呼び出しは
  // 黙って無視される(第4章)。だから 3 つのうち必要なものだけ使うシェーダーでも動く。
  const timeLocation = gl.getUniformLocation(program, 'u_time');
  const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
  const mouseLocation = gl.getUniformLocation(program, 'u_mouse');

  // この canvas ではこのプログラムしか使わないので、状態として一度設定すれば十分
  gl.useProgram(program);

  // u_mouse は「描画バッファのピクセル・左下原点」= gl_FragCoord と同じ座標系。
  // ポインタが一度も動いていない間は画面中央を渡す(描画ループ側で分岐)
  let pointerActive = false;
  let mouseX = 0;
  let mouseY = 0;

  function onPointerMove(event: PointerEvent): void {
    const rect = canvas.getBoundingClientRect();
    // DOM 座標(左上原点・CSS ピクセル)→ 左下原点・描画バッファのピクセルへ変換。
    // rect はボーダーを含む外形なので、clientLeft/Top(ボーダー幅)を引いて
    // 描画領域内の位置にし、ボーダーを除いたサイズ clientWidth/Height で換算する。
    // canvas.width / canvas.clientWidth が「CSS 1px あたりの描画バッファのピクセル数」(≒ dpr)
    const x = event.clientX - rect.left - canvas.clientLeft;
    const y = event.clientY - rect.top - canvas.clientTop;
    const scaleX = canvas.width / canvas.clientWidth;
    const scaleY = canvas.height / canvas.clientHeight;
    mouseX = x * scaleX;
    mouseY = (canvas.clientHeight - y) * scaleY;
    pointerActive = true;
  }
  canvas.addEventListener('pointermove', onPointerMove);

  // 毎フレーム先頭でサイズを確認し、変わっていたときだけ描画バッファを作り直す。
  // 毎フレーム確認するので、ResizeObserver も dpr 変化の監視も要らない(第2・4章)
  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, maxDpr);
    // display: none などで表示サイズが 0 のときも、バッファは最低 1px 確保しておく
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
  }

  let rafId = 0;

  function render(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();

    const width = gl.drawingBufferWidth;
    const height = gl.drawingBufferHeight;
    gl.viewport(0, 0, width, height);

    // 標準 uniform を更新する。時間は rAF の timestamp(ミリ秒)を秒に直したもの
    gl.uniform1f(timeLocation, timestamp * 0.001);
    gl.uniform2f(resolutionLocation, width, height);
    if (pointerActive) {
      gl.uniform2f(mouseLocation, mouseX, mouseY);
    } else {
      gl.uniform2f(mouseLocation, width / 2, height / 2);
    }

    // VAO も VBO もバインドしないまま「3 頂点で三角形 1 枚」を描く。
    // 頂点データは使わず、頂点シェーダーが gl_VertexID から座標を作る。
    // 三角形が毎フレーム全ピクセルを塗り直すので、clear も不要
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    rafId = requestAnimationFrame(render);
  }
  rafId = requestAnimationFrame(render);

  return {
    stop(): void {
      cancelAnimationFrame(rafId);
      canvas.removeEventListener('pointermove', onPointerMove);
    },
  };
}
src/lessons/06-fullscreen-shader/first.frag
#version 300 es

// 第6章のデモ: ハーネスの標準 uniform を 3 つとも使う最小のフラグメントシェーダー。
// 使っている関数は mix / sin / length / min だけ。
// 本格的な 2D 表現の数学(UV・アスペクト比・形)は第7章から。

precision highp float;

// ハーネス(src/lib/fullscreen-shader.ts)が毎フレーム更新してくれる標準セット
uniform float u_time; // 経過時間(秒)
uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)
uniform vec2 u_mouse; // ポインタ位置(ピクセル・左下原点 = gl_FragCoord と同じ)

out vec4 fragColor;

void main() {
  // このピクセルの位置を 0〜1 に正規化する(いわゆる UV 座標。詳しくは第7章)
  vec2 uv = gl_FragCoord.xy / u_resolution;

  // 2 色を uv.x の割合で混ぜ、その割合を時間でゆっくり揺らす。
  // mix の第 3 引数は clamp されない(0〜1 の外では外挿になる)ため、0〜1 に収めておく
  vec3 colorA = vec3(0.10, 0.14, 0.35); // 深い青
  vec3 colorB = vec3(0.90, 0.55, 0.25); // オレンジ
  float t = clamp(uv.x + 0.15 * sin(u_time * 0.8 + uv.y * 4.0), 0.0, 1.0);
  vec3 color = mix(colorA, colorB, t);

  // ポインタに近いピクセルほど少し明るくする。
  // u_mouse は gl_FragCoord と同じ座標系なので、変換なしでそのまま距離が取れる
  float dist = length(gl_FragCoord.xy - u_mouse);
  float glow = 1.0 - min(dist / (0.6 * u_resolution.y), 1.0);
  color += 0.2 * glow;

  fragColor = vec4(color, 1.0);
}

three.js との対応

three.js で「全画面シェーダー」をやるときの定番構成と、この章のハーネスは同じことをしています。

three.js の定番構成この章のハーネス
PlaneGeometry(2, 2)(クリップ空間ちょうどの板)+ 変換が実質単位行列になるOrthographicCamera(-1, 1, 1, -1, 0, 1)全画面三角形。カメラは不要(頂点シェーダーがクリップ座標を直接出力)。なお three.js 自身も、ポストプロセス内部のFullScreenQuadでは板ではなく同じ全画面三角形を使っています
ShaderMaterialfragmentShader を渡すcompileShaderlinkProgram(src/lib/shader.ts)
renderer.setAnimationLoop(callback)requestAnimationFrame ループ。stop() で解除
renderer.setSize/ setPixelRatio をリサイズ時に自分で呼ぶループ先頭の resizeIfNeeded() が毎フレーム面倒を見る
uniforms.uTime.value = ...を毎フレーム自分で代入する(three.js に時間 uniform の自動更新はない)u_time/u_resolution/u_mouseはハーネスが自動更新

手元で動かして、壊してみる

デモのコードはsrc/lessons/06-fullscreen-shader/、共通モジュールはsrc/lib/にあります。今回はハーネス側も自分のコードなので、遠慮なく両方を壊してください。

まとめ

次章からは第2部「シェーダーで学ぶ数学」です。このハーネスの上で、UV 座標と原点の取り方、第3章からの宿題だったアスペクト比の補正、そしてmix/step/smoothstepによるグラデーション設計 — 2D 表現の数学をいよいよ始めます。