第3部 3D ラスタライズパイプライン — 第16章

テクスチャ — フィルタ・ラップ・ミップマップ・複数ユニット

第14章で作ったジオメトリは、位置・法線に加えてUVという属性を持っていました。市松模様で「ちゃんと張れているな」と確認しただけで、そのまま しまい込んでいたあの座標が、この章の主役です。UV は画像の中の住所で、 テクスチャを貼るとは「この頂点には画像のここが来る」と指定することにほかなりません。

three.js ならnew THREE.TextureLoader().load('uv-grid.png')で得たTextureをマテリアルのmapに差すだけの作業です。しかしその 1 行の裏では、画像の非同期読み込み、上下の反転、 拡大縮小時の補間方法の選択、縮小用の縮小画像列(ミップマップ)の生成、範囲外の座標の扱い、 そして「テクスチャユニット」という WebGL 独特の間接参照が動いています。この章では、その全部を 自分の手で組み立てます。第4章のuniform1i、第5章のtexture/texelFetch、第6章の Shadertoy のiChannel、第7章の「画像を扱う日が来たら上下反転を思い出す」— これまでに撒いた 伏線が、まとめて回収される章でもあります。

ドラッグで回転、ホイールで距離を変えられます。1 段目は形状と、y 反転・2 枚目のテクスチャの合成。2 段目は拡大・縮小フィルタ、3 段目はラップモードと UV の倍率です。貼ってあるのは 256×256 のuv-grid.png— 上端が赤帯、下端が青帯、左端が白帯で、右下 1/4 だけ 4px の細かい市松になっています。 読み出し行には、テクスチャに書き込んである設定と、最後に画像を転送したときの条件を 表示しています。カメラは第15章の軌道カメラを簡略化したものです(減衰なし・注視点は原点固定)。

この章で学ぶこと:

1. テクスチャオブジェクトを作り、画像を送り込む

WebGL のリソースはどれも「作る → バインドする → 操作する」の三拍子でした(第3章の VBO、第13章の VAO と IBO)。テクスチャも同じです。

引っかかるのは 2 つ目の「バインド」です。VBO のときはgl.ARRAY_BUFFERという1 つの置き場所に差し込むだけでしたが、テクスチャの置き場所は複数あります。これが 6 節で扱うテクスチャユニットで、bindTextureは「いま選ばれているユニット」に対して働きます。この章のコードでbindTextureの前に必ずgl.activeTexture(gl.TEXTURE0)が出てくるのは、そのためです(理由は 6 節でまとめて説明します)。

画像は待ってくれない

JavaScript から画像を読むのは非同期の仕事です。gl.texImage2DHTMLImageElementを渡すと、テクスチャの幅と高さはその時点のビットマップの寸法になります。 読み込みの終わっていない画像を渡しても意味のある絵にはならないので、まず待ちます。昔ながらのimage.onloadでもよいのですが、HTML 仕様にはdecode()という Promise を返すメソッドがあり、これは「デコードまで完了した」ことを保証してくれます (失敗するとEncodingErrorで reject されます)。

src/lessons/16-textures/main.ts(抜粋)
/**
 * 画像を読み込む。decode() は「デコードが終わるまで待つ」Promise を返すので、
 * これが解決した時点で texImage2D にそのまま渡せる(失敗時は EncodingError で reject)
 */
async function loadImage(url: string): Promise<HTMLImageElement> {
  const image = new Image();
  image.src = url;
  await image.decode();
  return image;
}

問題は「待っている間、どうするか」です。シェーダーが参照しているテクスチャが空のままだと、 最初の数フレームの絵が保証できません。定石は1×1 の単色テクスチャを先に作っておくこと。これは three.js もやっていて、内部の状態管理 (WebGLState)は 1×1 のemptyTexturesを最初に用意し、まだ準備できていないテクスチャの代わりにバインドします。

src/lessons/16-textures/main.ts(抜粋)
/**
 * 1×1 の単色テクスチャ。画像が届くまでの間に合わせに使う。
 * 1×1 はミップレベルが level 0 の 1 段しかないので、この時点で「ミップマップ完備」。
 * MIN_FILTER をミップ系にしても不完全なテクスチャにならない
 */
function createPlaceholderTexture(rgba: readonly [number, number, number, number]): WebGLTexture {
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // 幅・高さ・border を数値で渡す 9 引数版。中身は ArrayBufferView(ここでは 4 バイト)
  const pixel = new Uint8Array(rgba);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
  return texture;
}

texImage2Dの引数は多いので、いま渡しているものを並べておきます。gl.TEXTURE_2D(対象)、0(ミップレベル。level 0 が原寸)、gl.RGBA8(GPU 側の格納形式)、幅1、高さ10(border。WebGL では常に 0)、gl.RGBA(渡すデータの 並び)、gl.UNSIGNED_BYTE(渡すデータの型)、そしてデータ本体です。HTMLImageElementを渡すときは幅・高さ・border を省いた短い版が使えます(2 節のコード)。

プレースホルダのおかげで、画像が届く前にデモの組み立てをすべて終えられます。 プログラムのリンク、VAO の作成、フィルタの設定、uniform の割り当て — 全部先に済ませ、 画像が届いたらtexImage2Dで level 0 を差し替えるだけ。これは実質的に three.js のtexture.needsUpdate = trueと同じことをしています。

2. 上下が逆になる — UNPACK_FLIP_Y_WEBGL

第7章で予告した「画像を扱う日が来たら思い出す」話です。デモの「y 反転: OFF」を押すと、 赤帯が下に、青帯が上に来ます。画像は明らかに上下逆さまです。

原因は、2 つの座標系が正反対を向いていることです。PNG や JPEG のようなラスター画像は上の行から順に並んでいます(1 行目が画像の最上段)。一方、テクスチャ座標のv下から上へ増えます — 第7章で見た「WebGL の y は上向き」と同じ流儀で、src/lib/geometry.tsが生成する UV も v = 0 が下端です。素直に転送すると、画像の 1 行目(いちばん上の赤帯)が v = 0(いちばん下)に置かれ、上下が入れ替わります。

画像ファイル赤帯 = 行 0青帯 = 最終行行番号が増える向き ↓falsetrueUNPACK_FLIP_Y_WEBGL = false青帯赤帯v = 0v = 1上下が逆さまUNPACK_FLIP_Y_WEBGL = true赤帯青帯v = 0v = 1画像は上の行から並び、v は下から増える。だから何もしないとすれ違う
y 反転の正体。画像の行の並びとテクスチャ座標 v の向きが逆なので、そのまま転送すると上下が 入れ替わります。WebGL はこのために、転送時に行の順序をひっくり返すスイッチを用意しています。

解決策は 2 つあります。1 つはシェーダー側でvec2(uv.x, 1.0 - uv.y)と読み替えること。もう 1 つが WebGL 専用のピクセル格納パラメータUNPACK_FLIP_Y_WEBGLです。WebGL 1.0 仕様の「Pixel Storage Parameters」に「これを設定すると、以後のtexImage2D/texSubImage2Dでソースデータが縦方向に反転され、概念的には最終行が最初に転送される」と定義されています (初期値はfalse)。転送の時点で並べ替えてしまうので、シェーダーは何も知らなくて済みます。

src/lessons/16-textures/main.ts(抜粋)
/** 画像を level 0 に転送し、縮小用の段を作らせる。y 反転を切り替えるたびに呼び直す */
function uploadGridImage(): void {
  if (!gridImage) return;
  bindForEdit(gridTexture);
  // 転送の設定であってテクスチャの設定ではない。しかもコンテキスト全体の状態なので、
  // 「いつ誰が false に戻したか」に依存しないよう、転送の直前に必ず指定する
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, gridImage);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
  uploadedFlipY = flipY ? 1 : 0; // 読み出し行に出すのは、この転送に効いた値
  // 1/2, 1/4, ... の段を GPU に作らせる。これを忘れて MIN_FILTER をミップ系にすると
  // 「不完全なテクスチャ」になり、texture() は (0, 0, 0, 1) = 不透明な黒を返す
  gl.generateMipmap(gl.TEXTURE_2D);
}

頂点シェーダー側は、第14章が生成した UV をそのまま流すだけです。三角形の内側では 3 頂点の UV が補間され、ピクセルごとに違う (u, v) が届きます — 第3章で頂点の色が補間されたのとまったく 同じ仕組みで、運ばれるのが色ではなく「画像の住所」になっただけです。

src/lessons/16-textures/textures.vert(抜粋)
// 頂点が持つ UV をそのままフラグメントへ渡す。三角形の内側では
// 3 頂点の UV が補間され、ピクセルごとに違う (u, v) が届く(第3章の色の補間と同じ仕組み)
v_uv = a_uv;

3. フィルタ — 拡大と縮小は別問題

テクスチャは有限の格子(テクセル)でできています。256×256 の画像を画面上で 500px 四方に 描けば、1 テクセルが複数ピクセルにまたがります(拡大 / magnification)。 逆に遠くの小さな板に貼れば、1 ピクセルに何十テクセルも詰め込まれます(縮小 / minification)。この 2 つは性質がまったく違うので、WebGL は別々のパラメータを持っています。

NEARESTは「いちばん近いテクセルの値をそのまま返す」、LINEARは「近い 4 テクセルの重み付き平均を返す」— OpenGL ES 3.0 のglTexParameterリファレンスは、MIN_FILTERLINEARをこの言葉で定義しています。MAG_FILTERの項だけは個数を書かず「最も近いテクセルの重み付き平均」ですが、仕様 §3.8.11 が「拡大のLINEARは縮小のLINEARとまったく同じ振る舞い」と定めており、2D テクスチャの補間式(§3.8.10.2)はどちらも 2×2 の 4 テクセルの重み付き和です。デモでNEARESTを選んで思い切り近づくと、色セルの境界がドット絵のようにカクカクになります。LINEARならなめらかにぼけます。どちらが正しいということはなく、ピクセルアートを扱うならNEARESTが正解です。

src/lessons/16-textures/main.ts(抜粋)
// 拡大と縮小で使われる関数は別物なので、MAG と MIN を 1 組にして持つ
const FILTER_MODES = [
  {
    label: 'NEAREST',
    mag: gl.NEAREST,
    magLabel: 'NEAREST',
    min: gl.NEAREST,
    minLabel: 'NEAREST',
  },
  { label: 'LINEAR', mag: gl.LINEAR, magLabel: 'LINEAR', min: gl.LINEAR, minLabel: 'LINEAR' },
  {
    label: 'ミップマップ',
    mag: gl.LINEAR,
    magLabel: 'LINEAR',
    min: gl.LINEAR_MIPMAP_LINEAR,
    minLabel: 'LINEAR_MIPMAP_LINEAR',
  },
] as const;

設定を書き込む先はテクスチャオブジェクトです。シェーダーでもプログラムでも なく、テクスチャ自身が「自分はどう読まれるか」を覚えています。だから同じ画像を別々の設定で 使いたければ、テクスチャオブジェクトを 2 つ作るか、WebGL2 で追加されたサンプラーオブジェクト (sampler object)を使うことになります(この章では扱いません)。

src/lessons/16-textures/main.ts(抜粋)
/** フィルタとラップはテクスチャオブジェクトの状態。切り替えるたびに書き込み直す */
function applySampling(): void {
  const filter = FILTER_MODES[filterIndex];
  const wrap = WRAP_MODES[wrapIndex];
  bindForEdit(gridTexture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter.mag);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter.min);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap.wrap);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap.wrap);
  updateReadout();
}

4. ミップマップ — 縮小のちらつきを消す

デモをホイールで思い切りズームアウトし、フィルタをLINEARにしたまま右下の細かい市松を見てください。模様がぐちゃぐちゃに乱れ、少しカメラを動かすと ちらちらと沸き立ちます。これがモアレ (moiré)、より一般にはエイリアシングです。

理屈は単純です。LINEARが見るのは近い 4 テクセルだけ。1 ピクセルの中に 64 テクセルぶんの模様が詰まっている状況で 4 つだけ拾えば、拾った 4 つがたまたま白か黒かで 結果が激変します。カメラが半ピクセル動いただけで拾うテクセルが変わり、絵が沸きます。 OpenGL ES 3.0 のリファレンスもNEARESTLINEARの縮小について「1 個ないし数個のテクセルしか見ないので、モアレやギザギザの遷移を生むことがある」と述べています。

根本的な解決は「1 ピクセルが覆う範囲全体の平均を返す」ことですが、毎フレーム 何十テクセルも平均していては話になりません。そこで、あらかじめ縮小した画像を作り置きしておくのがミップマップ (mipmap) です。256×256 の下に 128×128、64×64…と 1×1 まで 9 段を用意し、 描画時に「いま必要な縮小率にいちばん近い段」から読みます。平均はオフラインで済んでいるので、 実行時のコストは 1〜2 回のサンプリングのままです。

ミップマップ(256×256 なら 9 段)level 0 / 256²1 / 128²2 / 64²3… 8 / 1²段数 = floor(log2(長辺)) + 1追加のメモリはおよそ +33%MIN_FILTER のミップ系 4 種段の選び方段内 NEAREST段内 LINEAR1 段だけ_MIPMAP_NEARESTN_MIPMAP_NL_MIPMAP_N2 段を混ぜる_MIPMAP_LINEARN_MIPMAP_LL_MIPMAP_L右下 = トライリニア(いちばんなめらか)N = NEAREST / L = LINEAR の略
ミップマップの段と、MIN_FILTERの 4 種の関係。「段をいくつ使うか(1 つ / 2 つ混ぜる)」×「段の中で最近傍か線形か」の 2 軸で名前ができています。LINEAR_MIPMAP_LINEARは 2 段それぞれで 4 テクセルを平均し、その 2 つをさらに混ぜるのでトライリニアと呼ばれます。

名前の読み方さえ分かれば 4 種は一目です。A_MIPMAP_BAが「選んだ段の中でどう読むか」、Bが「段をどう選ぶか」。BNEARESTなら縮小率にいちばん近い段を 1 つ、LINEARなら前後 2 段を読んで補間します。段の切り替わりが帯として見えるのを防げるのが後者で、LINEAR_MIPMAP_LINEAR(トライリニア)が最もなめらかです。

段の中身は自分で用意してもよいのですが(texImage2Dの level を 1, 2, … と変えて渡す)、原寸から機械的に作れるものなのでgl.generateMipmap(gl.TEXTURE_2D)に任せるのが普通です。仕様は「level 0 を繰り返し縮小して level 1 以降を作り直す」とだけ定め、 具体的なフィルタは実装に委ねています(ボックスフィルタが推奨)。

5. ラップ — UV が 0〜1 の外に出たら

UV は 0〜1 に収まっている必要はありません。フラグメントシェーダーでv_uv * 2.5と書けば、面の端では u も v も 2.5 まで伸びます。そのとき「1.7 番地」に何があるかを決めるのがラップモード (wrap mode)で、s(横)と t(縦)に別々に設定できます。

デモの 3 段目で試してください。「CLAMP ×1」は UV を等倍のまま使うので、どのラップモードでも 見た目は同じです。「CLAMP ×2.5」にすると、画像は左下 1/2.5 の領域に収まり、上側は v = 1 の行(y 反転 ON なら赤帯)が、右側は画像の右端の列が引き伸ばされて縞になります。左端の白帯は u が 0 を下回らないので出番がありません。「REPEAT ×2.5」なら 2.5 枚ぶんのタイル、 「MIRRORED ×2.5」なら鏡像で折り返したタイルです。UV に倍率をかけているのはシェーダー側で、 ジオメトリの UV は一切いじっていません。

6. テクスチャユニットと uniform1i(第4章の回収)

第4章の uniform 早見表に、1 行だけ意味の分からない行があったはずです —「int/bool/sampler2Duniform1i(loc, i)→ テクスチャユニット番号(第16章)」。

sampler2Dに整数を入れる、というのは奇妙に見えます。しかしこれは仕様どおりの唯一の方法で、OpenGL ES 3.0 仕様は「サンプラーの値はUniform1iで設定しなければならない。ほかのUniform*で読み込むことは許されず、INVALID_OPERATIONになる」と明記しています。そして「サンプラーの値を i にすると、テクスチャイメージユニット 番号 i が選ばれる」。

つまりGPU には「テクスチャの差し込み口」が何本も並んでいて、シェーダーの sampler が持つのは口の番号だけです。テクスチャそのものを uniform として渡す API は存在しません。 描画するときの手順は、いつも次の三点セットになります。

  1. gl.activeTexture(gl.TEXTURE0 + i)— これから操作する口を選ぶ(状態機械のスイッチ)
  2. gl.bindTexture(gl.TEXTURE_2D, texture)— 選んだ口にテクスチャを差す
  3. gl.uniform1i(location, i)— シェーダーの sampler に「あなたは i 番の口を見なさい」と教える
テクスチャオブジェクトgridTextureaccentTextureテクスチャユニットTEXTURE0TEXTURE1TEXTURE2(空)bindTexturebindTextureどの口に差すかはactiveTextureが決めるフラグメントシェーダーu_texture = 0u_accentTexture = 1uniform1i で入れた整数がユニット番号
sampler が持っているのは「口の番号」だけです。テクスチャそのものはbindTextureでユニットに差さっており、activeTextureはそのとき「どの口をいじっているか」を切り替えるスイッチです。

番号は描画中に変わらないので、uniform1iは初期化時に 1 回で済みます。useProgramの後であることだけ守ってください(uniform はプログラムに紐づく状態です。第4章)。

src/lessons/16-textures/main.ts(抜粋)
// sampler に渡すのはテクスチャではなくユニット番号。int なので uniform1i(第4章)。
// 番号は描画中に変えないので、ここで 1 回入れれば済む
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0);
gl.uniform1i(gl.getUniformLocation(program, 'u_accentTexture'), 1);

毎フレームやるのは、ユニットへの差し替えだけです。

src/lessons/16-textures/main.ts(抜粋)
// テクスチャユニットの三点セット。ユニットを選び、そこにテクスチャをバインドし、
// sampler にはユニット番号を入れる(番号は初期化時に入れ済み)
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, accentTexture);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gridTexture);
src/lessons/16-textures/main.ts(抜粋)
/**
 * 設定のためにテクスチャをバインドする。bindTexture が効くのは
 * 「いま選ばれているテクスチャユニット」なので、作業台をユニット 0 に固定しておく。
 * これを省くと、描画ループが最後に選んだユニットの中身を書き換えてしまう
 */
function bindForEdit(texture: WebGLTexture): void {
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
}

ユニットは何本あるのか、は実装依存です。gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)(フラグメントシェーダーから同時に使える数)やgl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)(頂点 + フラグメントの合計)で問い合わせられます。gl.TEXTURE0 + iという書き方が許されるのは、仕様が「TEXTUREi = TEXTURE0 + iが成り立つ」と保証しているからです。

7. GLSL 側 — sampler2D・texture・texelFetch(第5章の回収)

シェーダー側の登場人物は 3 つだけです。第5章の型の節と組み込み関数カタログで名前だけ 紹介したものが、ここで実物になります。

src/lessons/16-textures/textures.frag(抜粋)
// sampler2D は「テクスチャそのもの」ではなく「テクスチャユニットへの参照」。
// CPU 側は uniform1i(loc, i) でユニット番号 i を入れる(第4章の uniform 表の回収)
uniform sampler2D u_texture;
uniform sampler2D u_accentTexture;
src/lessons/16-textures/textures.frag(抜粋)
// 頂点から来た UV は加工してよい。ここでは倍率をかけて 0〜1 の外へはみ出させる
vec2 uv = v_uv * u_uvScale;

// texture() は UV(0〜1 の実数)で読む。フィルタもラップもこの 1 行の内側で効く
vec4 base = texture(u_texture, uv);

texture(sampler, uv)が返すのはvec4(RGBA)です。この 1 行の内側で、ラップモードによる座標の折り返し、 ミップレベルの選択、テクセルの補間まで全部が行われます。WebGL1 のtexture2Dが改名されたもので、GLSL ES 3.00 ではサンプラーの型から自動的に判別されます(第5章の 読み替え表のとおりです)。

もう 1 つがtexelFetch(sampler, ivec2, lod)です。こちらは整数のテクセル座標で 1 テクセルだけを取り出します。 OpenGL ES 3.0 仕様の「Texel Fetches」の定義を要約すると:

使い分けはこうです。「絵として貼る」なら texture— 拡大縮小がなめらかに処理され、UV の意味も「面のどこか」という比率で自然です。「配列として読む」なら texelFetch— テクスチャを「GPU 上の 2 次元配列」として扱い、i 行 j 列の値をそのまま取りたい場面です。 ルックアップテーブル、パーティクルの状態、そして前フレームの計算結果など、 値がにじんでは困るデータを読むときに使います。

src/lessons/16-textures/textures.frag(抜粋)
// texelFetch を試すときは上の base の行をこれに差し替える(本文 7 節)。
// 整数のテクセル座標で 1 テクセルだけ読むので、フィルタもラップも通らない。
// 範囲外は結果が未定義なので、REPEAT 相当の折り返しは自分で % を取って作る:
// ivec2 size = textureSize(u_texture, 0);
// vec4 base = texelFetch(u_texture, ivec2(uv * vec2(size)) % size, 0);

差し替えて動かすと、NEARESTとよく似た見た目になります。しかし中身は別物で、フィルタの設定をLINEARに変えても何も変わりません — texelFetchはフィルタ段を通らないからです。ラップも効かないので、はみ出した座標は自分で%を取って畳んでいます。texelFetch第29章のフィードバック(前フレームのバッファをピクセル単位で読み、 次のフレームを計算する)で本領を発揮します。第5章のカタログがtexelFetchの出番を「第16章・第29章」と書いていたのは、この 2 段構えのことです。

8. texImage2D と texStorage2D — 2 つの流儀

ここまでのtexImage2Dは、呼ぶたびに「そのレベルのサイズと形式」を宣言し直します。1×1 で作ったテクスチャに 256×256 を上書きできたのは、そのおかげです。柔軟な代わりに、GPU ドライバは「いつサイズが変わるか分からない」前提で構えることになります。

WebGL2(OpenGL ES 3.0)では、もう 1 つの流儀が追加されました。不変ストレージ (immutable-format texture)です。texStorage2D(target, levels, internalformat, width, height)を 1 回呼ぶと、全ミップレベルのサイズと形式がその場で確定します。仕様の言葉では 「一度この命令でテクスチャを指定すると、全レベルの形式と寸法は不変になる。画像の中身と パラメータは変更できる」。以後、中身の書き込みはtexSubImage2Dで行い、texImage2Dを呼ぶとINVALID_OPERATIONになります。

src/lessons/16-textures/main.ts(抜粋)
/** accent 側は WebGL2 で追加された不変ストレージで作る(サイズが分かってから 1 回だけ) */
function createImmutableTexture(image: HTMLImageElement): WebGLTexture {
  const texture = gl.createTexture();
  bindForEdit(texture);
  // 全ミップレベルのサイズと形式をここで確定させる。段数 = floor(log2(長辺)) + 1
  const levels = Math.floor(Math.log2(Math.max(image.width, image.height))) + 1;
  gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, image.width, image.height);
  // 以降、中身の書き込みは texSubImage2D だけ(texImage2D は INVALID_OPERATION)
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
  gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
  gl.generateMipmap(gl.TEXTURE_2D);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return texture;
}

どちらを使うべきか。サイズが分かっているならtexStorage2Dです。理由は 2 つあります。1 つは、確保が 1 回で済み「あとから寸法が変わるかもしれない」という不確定性がなくなること。 もう 1 つは、レベル数を明示するのでミップ段の作り忘れが構造的に起きにくいことです。実際、three.js も WebGL2 では既定でこちらを使っています(WebGLTextures.jsuseTexStorageを立ててtexStorage2D+texSubImage2Dの組で転送しており、寸法が毎フレーム変わりうるビデオテクスチャだけを例外にしています)。

逆にtexImage2Dが要るのは、この章のデモのようにサイズが分かる前にテクスチャオブジェクトが必要な場合です。1×1 のプレースホルダを 256×256 に「育てる」ことは、不変ストレージではできません。 その場合は「小さいテクスチャで始めて、画像が届いたらtexStorage2Dで作った新しいテクスチャに差し替える」— デモの accent 側がやっているのが、まさにそれです。 差し替えたら、用済みになった 1×1 はgl.deleteTexture()で消しておきます。「作り直したら古いものを消す」が後始末の最小形で、リソース解放の一般論は第35章で扱います。

texSubImage2Dは不変ストレージ専用というわけではなく、「すでに確保済みの領域の一部だけを 書き換える」汎用の命令です。オフセットと幅・高さを指定できるので、スプライトシートの 1 コマだけ更新する、テクスチャアトラスに絵を追記する、といった使い方ができます。

9. 非 2 冪テクスチャ — WebGL2 で消えた制約

テクスチャのサイズを 256, 512, 1024 のような 2 の冪にする慣習を見たことがあるかもしれません。 これは WebGL1 時代の制約の名残です。OpenGL ES 2.0 仕様では、非 2 冪 (NPOT: non-power-of-two)の画像を 2D サンプラーで読むとき、ラップモードがCLAMP_TO_EDGEでないか、縮小フィルタがNEAREST/LINEAR以外だと結果が未定義と定められていました。実質「NPOT ではREPEATもミップマップも使えない」ということです。

WebGL 2.0 仕様は、WebGL1 からの変更点として次のように書いています。「テクスチャアクセスは OpenGL ES 3.0 API と同様に動作する。言い換えれば、WebGL 1.0 API とは異なり、非 2 冪テクスチャに特別な制限はない。非 2 冪の画像でも、すべてのミップマップとすべての ラップモードがサポートされる」。つまりWebGL2 では 300×200 の画像にREPEATをかけてもミップマップを作っても構いません

ミップの段数の式floor(log2(長辺)) + 1も NPOT でそのまま使えます。300×200 ならfloor(log2(300)) + 1 = 9段で、各段の寸法はmax(1, floor(元 / 2ⁱ))— 300 → 150 → 75 → 37 → 18 → 9 → 4 → 2 → 1 のように、割り切れないところは切り捨てられます。 2 冪に揃えたほうがメモリの扱いや圧縮フォーマットの都合がよい場面はいまも残っていますが、「WebGL だから 2 冪でなければならない」という理由はもうありません

コード全文

src/lessons/16-textures/main.ts
// 第16章: テクスチャ — フィルタ・ラップ・ミップマップ・複数ユニット
// 第14章で生成した UV に、いよいよ画像を貼る。
//
// 見どころ:
// - 画像の読み込みは非同期。届くまでは 1×1 のテクスチャでしのぐ(three.js も同じ)
// - UNPACK_FLIP_Y_WEBGL は「テクスチャの設定」ではなく「転送の設定」で、
//   コンテキスト全体の状態。アップロードのたびに明示する
// - フィルタ・ラップは texParameteri でテクスチャオブジェクトに書き込む
// - 2 枚目は別のテクスチャユニットへ。sampler に渡すのはユニット番号(uniform1i)
// - 主テクスチャは texImage2D(可変)、accent は texStorage2D(不変ストレージ)で作り、
//   WebGL2 の 2 つの流儀を 1 つのデモに同居させている

import { mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import {
  createPlane,
  createSphere,
  createTorus,
  type Geometry,
  interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './textures.frag?raw';
import vertexSource from './textures.vert?raw';

// ---------------------------------------------------------------------------
// 画像とメッシュ
// ---------------------------------------------------------------------------

const GRID_URL = '/textures/uv-grid.png'; // 256×256。上端が赤帯・下端が青帯・左端が白帯
const ACCENT_URL = '/textures/accent.png'; // 128×128。中央が明るい同心円

/**
 * 画像を読み込む。decode() は「デコードが終わるまで待つ」Promise を返すので、
 * これが解決した時点で texImage2D にそのまま渡せる(失敗時は EncodingError で reject)
 */
async function loadImage(url: string): Promise<HTMLImageElement> {
  const image = new Image();
  image.src = url;
  await image.decode();
  return image;
}

interface Mesh {
  vao: WebGLVertexArrayObject;
  indexCount: number;
}

function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  // 1 頂点 = 位置 3 + 法線 3 + UV 2 の 8 float(第14章の interleave)。
  // stride / offset はバイト単位で、数値を直書きせず BYTES_PER_ELEMENT から組み立てる(第13章)
  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES;
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
  // 法線(3 float 目から)はこの章のシェーダーが使わないので配線しない。第17章から使う
  gl.enableVertexAttribArray(2); // a_uv: 位置 + 法線 6 つ分(24 バイト)の後ろ
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  gl.bindVertexArray(null);
  return { vao, indexCount: geometry.indices.length };
}

// ---------------------------------------------------------------------------
// カメラ定数(第3部の標準)
// ---------------------------------------------------------------------------

const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;
const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  sceneControls: HTMLParagraphElement,
  filterControls: HTMLParagraphElement,
  wrapControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  // --- テクスチャの生成 -----------------------------------------------------

  /**
   * 1×1 の単色テクスチャ。画像が届くまでの間に合わせに使う。
   * 1×1 はミップレベルが level 0 の 1 段しかないので、この時点で「ミップマップ完備」。
   * MIN_FILTER をミップ系にしても不完全なテクスチャにならない
   */
  function createPlaceholderTexture(rgba: readonly [number, number, number, number]): WebGLTexture {
    const texture = gl.createTexture();
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    // 幅・高さ・border を数値で渡す 9 引数版。中身は ArrayBufferView(ここでは 4 バイト)
    const pixel = new Uint8Array(rgba);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
    return texture;
  }

  const gridTexture = createPlaceholderTexture([48, 52, 60, 255]);
  let accentTexture = createPlaceholderTexture([0, 0, 0, 255]);
  let gridImage: HTMLImageElement | null = null;

  /**
   * 設定のためにテクスチャをバインドする。bindTexture が効くのは
   * 「いま選ばれているテクスチャユニット」なので、作業台をユニット 0 に固定しておく。
   * これを省くと、描画ループが最後に選んだユニットの中身を書き換えてしまう
   */
  function bindForEdit(texture: WebGLTexture): void {
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, texture);
  }

  // --- 切替の状態 -----------------------------------------------------------

  // 拡大と縮小で使われる関数は別物なので、MAG と MIN を 1 組にして持つ
  const FILTER_MODES = [
    {
      label: 'NEAREST',
      mag: gl.NEAREST,
      magLabel: 'NEAREST',
      min: gl.NEAREST,
      minLabel: 'NEAREST',
    },
    { label: 'LINEAR', mag: gl.LINEAR, magLabel: 'LINEAR', min: gl.LINEAR, minLabel: 'LINEAR' },
    {
      label: 'ミップマップ',
      mag: gl.LINEAR,
      magLabel: 'LINEAR',
      min: gl.LINEAR_MIPMAP_LINEAR,
      minLabel: 'LINEAR_MIPMAP_LINEAR',
    },
  ] as const;

  const WRAP_MODES = [
    { label: 'CLAMP ×1', wrap: gl.CLAMP_TO_EDGE, wrapLabel: 'CLAMP_TO_EDGE', uvScale: 1 },
    { label: 'CLAMP ×2.5', wrap: gl.CLAMP_TO_EDGE, wrapLabel: 'CLAMP_TO_EDGE', uvScale: 2.5 },
    { label: 'REPEAT ×2.5', wrap: gl.REPEAT, wrapLabel: 'REPEAT', uvScale: 2.5 },
    {
      label: 'MIRRORED ×2.5',
      wrap: gl.MIRRORED_REPEAT,
      wrapLabel: 'MIRRORED_REPEAT',
      uvScale: 2.5,
    },
  ] as const;

  const shapes = [
    { label: '平面', mesh: createMesh(gl, createPlane(2.4, 2.4)) },
    { label: '球', mesh: createMesh(gl, createSphere(1.1, 48, 24)) },
    { label: 'トーラス', mesh: createMesh(gl, createTorus(0.85, 0.34, 64, 32)) },
  ] as const;

  let shapeIndex = 0;
  let filterIndex = 1; // 既定は LINEAR
  let wrapIndex = 0; // 既定は CLAMP_TO_EDGE・UV 等倍
  let flipY = true;
  let useAccent = false;
  // 「最後に画像を転送したときの UNPACK_FLIP_Y_WEBGL」。GL の現在値ではない
  // (転送のたびに 0 へ戻しているので、GL 側の現在値は常に 0)
  let uploadedFlipY = 0;

  function updateReadout(): void {
    const filter = FILTER_MODES[filterIndex];
    const wrap = WRAP_MODES[wrapIndex];
    const size = gridImage ? `${gridImage.width}×${gridImage.height}` : '読み込み中';
    const levels = gridImage
      ? Math.floor(Math.log2(Math.max(gridImage.width, gridImage.height))) + 1
      : 1;
    readout.textContent =
      `MAG_FILTER=${filter.magLabel} ` +
      `MIN_FILTER=${filter.minLabel} / WRAP_S=WRAP_T=${wrap.wrapLabel} UV×${wrap.uvScale} / ` +
      `最後の転送時 UNPACK_FLIP_Y_WEBGL=${uploadedFlipY}(現在の GL 状態は常に 0) / ` +
      `ユニット0=uv-grid ${size}(ミップ ${levels} 段)・` +
      `ユニット1=accent(${useAccent ? '合成 ON' : '合成 OFF'})`;
  }

  /** フィルタとラップはテクスチャオブジェクトの状態。切り替えるたびに書き込み直す */
  function applySampling(): void {
    const filter = FILTER_MODES[filterIndex];
    const wrap = WRAP_MODES[wrapIndex];
    bindForEdit(gridTexture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter.mag);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter.min);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap.wrap);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap.wrap);
    updateReadout();
  }

  /** 画像を level 0 に転送し、縮小用の段を作らせる。y 反転を切り替えるたびに呼び直す */
  function uploadGridImage(): void {
    if (!gridImage) return;
    bindForEdit(gridTexture);
    // 転送の設定であってテクスチャの設定ではない。しかもコンテキスト全体の状態なので、
    // 「いつ誰が false に戻したか」に依存しないよう、転送の直前に必ず指定する
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, gridImage);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
    uploadedFlipY = flipY ? 1 : 0; // 読み出し行に出すのは、この転送に効いた値
    // 1/2, 1/4, ... の段を GPU に作らせる。これを忘れて MIN_FILTER をミップ系にすると
    // 「不完全なテクスチャ」になり、texture() は (0, 0, 0, 1) = 不透明な黒を返す
    gl.generateMipmap(gl.TEXTURE_2D);
  }

  /** accent 側は WebGL2 で追加された不変ストレージで作る(サイズが分かってから 1 回だけ) */
  function createImmutableTexture(image: HTMLImageElement): WebGLTexture {
    const texture = gl.createTexture();
    bindForEdit(texture);
    // 全ミップレベルのサイズと形式をここで確定させる。段数 = floor(log2(長辺)) + 1
    const levels = Math.floor(Math.log2(Math.max(image.width, image.height))) + 1;
    gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, image.width, image.height);
    // 以降、中身の書き込みは texSubImage2D だけ(texImage2D は INVALID_OPERATION)
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    return texture;
  }

  // --- 切替ボタン -----------------------------------------------------------

  function addRadioButtons(
    container: HTMLParagraphElement,
    labels: readonly string[],
    initialIndex: number,
    onSelect: (index: number) => void,
  ): void {
    // 同じ列の中だけを見る。1 つの列に排他ボタンとトグルが混在してもよいように、
    // container.querySelectorAll ではなく自分で作ったボタンだけを覚えておく
    const buttons: HTMLButtonElement[] = [];
    for (const [index, label] of labels.entries()) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = label;
      button.setAttribute('aria-pressed', index === initialIndex ? 'true' : 'false');
      button.addEventListener('click', () => {
        for (const other of buttons) other.setAttribute('aria-pressed', 'false');
        button.setAttribute('aria-pressed', 'true');
        onSelect(index);
      });
      buttons.push(button);
      container.append(button);
    }
  }

  function addToggleButton(
    container: HTMLParagraphElement,
    label: (on: boolean) => string,
    initial: boolean,
    onChange: (on: boolean) => void,
  ): void {
    let on = initial;
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label(on);
    button.setAttribute('aria-pressed', String(on));
    button.addEventListener('click', () => {
      on = !on;
      button.textContent = label(on);
      button.setAttribute('aria-pressed', String(on));
      onChange(on);
    });
    container.append(button);
  }

  addRadioButtons(
    sceneControls,
    shapes.map((shape) => shape.label),
    shapeIndex,
    (index) => {
      shapeIndex = index;
    },
  );
  addToggleButton(
    sceneControls,
    (on) => `y 反転: ${on ? 'ON' : 'OFF'}`,
    flipY,
    (on) => {
      flipY = on;
      uploadGridImage(); // 反転は転送時にしか効かないので、画像を送り直す
      updateReadout();
    },
  );
  addToggleButton(
    sceneControls,
    (on) => `2 枚目: ${on ? 'ON' : 'OFF'}`,
    useAccent,
    (on) => {
      useAccent = on;
      updateReadout();
    },
  );

  addRadioButtons(
    filterControls,
    FILTER_MODES.map((mode) => mode.label),
    filterIndex,
    (index) => {
      filterIndex = index;
      applySampling();
    },
  );

  addRadioButtons(
    wrapControls,
    WRAP_MODES.map((mode) => mode.label),
    wrapIndex,
    (index) => {
      wrapIndex = index;
      applySampling();
    },
  );

  // --- 画像の読み込み(ここまでの準備はすべて画像なしで完了している) ---------

  loadImage(GRID_URL)
    .then((image) => {
      gridImage = image;
      uploadGridImage();
      applySampling(); // 1×1 のときに入れた設定を、本物のサイズで入れ直す
    })
    .catch((error: unknown) => {
      readout.textContent = `${GRID_URL} を読み込めませんでした: ${String(error)}`;
    });

  loadImage(ACCENT_URL)
    .then((image) => {
      // 1×1 のプレースホルダはもう使わない。作り直したら古いものを消す、が後始末の最小形
      gl.deleteTexture(accentTexture);
      accentTexture = createImmutableTexture(image);
    })
    .catch(() => {
      // 2 枚目は無くても主役は描けるので、1×1 の黒のままにしておく
    });

  // --- 描画の準備 -----------------------------------------------------------

  gl.useProgram(program);
  const modelLocation = gl.getUniformLocation(program, 'u_model');
  const viewLocation = gl.getUniformLocation(program, 'u_view');
  const projectionLocation = gl.getUniformLocation(program, 'u_projection');
  const uvScaleLocation = gl.getUniformLocation(program, 'u_uvScale');
  const useAccentLocation = gl.getUniformLocation(program, 'u_useAccent');

  // sampler に渡すのはテクスチャではなくユニット番号。int なので uniform1i(第4章)。
  // 番号は描画中に変えないので、ここで 1 回入れれば済む
  gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0);
  gl.uniform1i(gl.getUniformLocation(program, 'u_accentTexture'), 1);

  gl.enable(gl.DEPTH_TEST);
  // この章はカリングを切っている。板を裏側から眺めても消えないほうが、
  // フィルタやラップの効き方を確かめやすいため(第13章の CULL_FACE は有効のままでもよい)
  gl.disable(gl.CULL_FACE);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  applySampling();

  // --- 軌道カメラ(第15章の簡略版。減衰なし・注視点は原点固定) --------------

  const orbit = { theta: 0.0, phi: 0.18, radius: 3.4 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 1.2;
  const MAX_RADIUS = 24;

  let dragging = false;
  let activePointerId: number | null = null;
  let lastX = 0;
  let lastY = 0;

  canvas.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return;
    dragging = true;
    activePointerId = event.pointerId;
    lastX = event.clientX;
    lastY = event.clientY;
    canvas.setPointerCapture(event.pointerId);
  });

  canvas.addEventListener('pointermove', (event) => {
    if (!dragging || event.pointerId !== activePointerId) return;
    const speed = (2 * Math.PI) / canvas.clientHeight;
    orbit.theta -= (event.clientX - lastX) * speed;
    orbit.phi += (event.clientY - lastY) * speed;
    orbit.phi = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
    lastX = event.clientX;
    lastY = event.clientY;
  });

  function endDrag(event: PointerEvent): void {
    if (event.pointerId !== activePointerId) return;
    dragging = false;
    activePointerId = null;
    if (canvas.hasPointerCapture(event.pointerId)) {
      canvas.releasePointerCapture(event.pointerId);
    }
  }
  canvas.addEventListener('pointerup', endDrag);
  canvas.addEventListener('pointercancel', endDrag);

  canvas.addEventListener(
    'wheel',
    (event) => {
      event.preventDefault();
      // 単位を px 相当に揃える(第15章)。ズームアウトすると縮小フィルタの差が出る
      const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
      orbit.radius *= Math.exp(event.deltaY * scale * 0.001);
      orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));
    },
    { passive: false },
  );

  // --- リサイズ -------------------------------------------------------------

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    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;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- 描画ループ -----------------------------------------------------------

  const eye = vec3.create();
  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();

  function frame(): void {
    resizeIfNeeded();

    eye[0] = orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
    eye[1] = orbit.radius * Math.sin(orbit.phi);
    eye[2] = orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    mat4.lookAt(view, eye, TARGET, UP);
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
    mat4.identity(model);

    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.uniformMatrix4fv(modelLocation, false, model); // transpose は常に false
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);
    gl.uniform1f(uvScaleLocation, WRAP_MODES[wrapIndex].uvScale);
    gl.uniform1f(useAccentLocation, useAccent ? 1.0 : 0.0);

    // テクスチャユニットの三点セット。ユニットを選び、そこにテクスチャをバインドし、
    // sampler にはユニット番号を入れる(番号は初期化時に入れ済み)
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, accentTexture);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, gridTexture);

    const mesh = shapes[shapeIndex].mesh;
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);

    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止も canvas に付けた
  // 5 つのリスナーの解除もしていない。リソース解放の一般論は第35章
  requestAnimationFrame(frame);
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const sceneControls = document.querySelector<HTMLParagraphElement>('#scene-buttons');
const filterControls = document.querySelector<HTMLParagraphElement>('#filter-buttons');
const wrapControls = document.querySelector<HTMLParagraphElement>('#wrap-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !sceneControls || !filterControls || !wrapControls || !readout) {
  throw new Error('デモに必要な要素が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, sceneControls, filterControls, wrapControls, readout);
src/lessons/16-textures/textures.vert
#version 300 es

// 第3部の共通の属性配置(第14章と同じ): 0 = 位置, 1 = 法線, 2 = UV。
// この章のシェーダーは法線を使わないので location = 1 は宣言しない
// (CPU 側もその配線を有効にしていない。法線が主役になるのは第17章)
layout(location = 0) in vec3 a_position;
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec2 v_uv;

void main() {
  // 頂点が持つ UV をそのままフラグメントへ渡す。三角形の内側では
  // 3 頂点の UV が補間され、ピクセルごとに違う (u, v) が届く(第3章の色の補間と同じ仕組み)
  v_uv = a_uv;

  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/16-textures/textures.frag
#version 300 es
precision highp float;

in vec2 v_uv;

// sampler2D は「テクスチャそのもの」ではなく「テクスチャユニットへの参照」。
// CPU 側は uniform1i(loc, i) でユニット番号 i を入れる(第4章の uniform 表の回収)
uniform sampler2D u_texture;
uniform sampler2D u_accentTexture;

// UV を何倍に引き伸ばすか。1.0 を超えるとラップモードの出番になる
uniform float u_uvScale;
// 0.0 = 1 枚だけ / 1.0 = 2 枚目(accent)を加算合成
uniform float u_useAccent;

out vec4 fragColor;

void main() {
  // 頂点から来た UV は加工してよい。ここでは倍率をかけて 0〜1 の外へはみ出させる
  vec2 uv = v_uv * u_uvScale;

  // texture() は UV(0〜1 の実数)で読む。フィルタもラップもこの 1 行の内側で効く
  vec4 base = texture(u_texture, uv);

  // 2 枚目は別のユニットにバインドされた別のテクスチャ。
  // タイリングさせたくないので、こちらは倍率をかけない生の v_uv で読む
  vec4 accent = texture(u_accentTexture, v_uv);

  // texelFetch を試すときは上の base の行をこれに差し替える(本文 7 節)。
  // 整数のテクセル座標で 1 テクセルだけ読むので、フィルタもラップも通らない。
  // 範囲外は結果が未定義なので、REPEAT 相当の折り返しは自分で % を取って作る:
  // ivec2 size = textureSize(u_texture, 0);
  // vec4 base = texelFetch(u_texture, ivec2(uv * vec2(size)) % size, 0);

  fragColor = vec4(base.rgb + accent.rgb * u_useAccent, 1.0);
}

three.js との対応

この章の内容は、three.js のTextureのプロパティとほぼ 1 対 1 で対応します。既定値が WebGL の初期値とずれている箇所があるので、 そこも併記しておきます(three.js の値はsrc/textures/Texture.jsのコンストラクタ、WebGL の値は OpenGL ES 3.0 仕様のglTexParameterより)。

three.jsこの章
new THREE.TextureLoader().load(url)Image+ decode()texImage2D。three.js も同じで、loadは空のTextureを即座に返し、画像が届いてからneedsUpdateを立てる。届くまでの 1×1 もWebGLStateemptyTextures として実在する
texture.flipY(既定 true)gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)。WebGL の初期値はfalseなので、three.js は「反転する」側を既定にしている
texture.wrapS/ wrapT(既定THREE.ClampToEdgeWrapping)TEXTURE_WRAP_S/ TEXTURE_WRAP_T。WebGL の初期値はREPEATなので、ここも既定が逆
texture.magFilter(既定 LinearFilter)/minFilter(既定 LinearMipmapLinearFilter)TEXTURE_MAG_FILTER/ TEXTURE_MIN_FILTER。WebGL の初期値はLINEAR/ NEAREST_MIPMAP_LINEAR
texture.generateMipmaps(既定 true)gl.generateMipmap(gl.TEXTURE_2D)。three.js が既定で作ってくれるおかげで、4 節の「真っ黒」に出会いにくい
texture.repeat / offsetフラグメントシェーダーでv_uv * u_uvScale(three.js は内部で UV 変換行列を組み立てている)
material.map = textureuniform sampler2D u_texture+activeTexture/ bindTexture / uniform1i の三点セット
texture.anisotropy拡張機能EXT_texture_filter_anisotropic(この章では扱わない)
texture.colorSpace(既定 NoColorSpace。色データにはSRGBColorSpaceを指定)この章では素通し。リニアと sRGB の行き来は第27章
WebGL2 バックエンドの texStorage2D 経路texStorage2D+ texSubImage2D(8 節)。three.js もビデオテクスチャ以外はこの経路を使う

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

この章のコードはsrc/lessons/16-textures/に、画像はpublic/textures/にあります。テクスチャは「設定を 1 つ間違えると分かりやすく壊れる」ので、実験の効きがよい題材です。

まとめ

面に絵が貼れるようになりました。しかしデモの球は、どこから見ても同じ明るさで、 ボールというより「球形のシール」に見えるはずです。立体を立体らしく見せているのは、 実は模様ではなく陰影だからです。次章第17章「ライティング基礎 — 法線・拡散反射・鏡面反射」では、第14章から持ち歩いていたもう 1 つの属性、法線がついに主役になります。光の向きと法線のdot(第11章の「向きの一致度メーター」)から明るさを作り、この章で貼った テクスチャに陰影を重ねます。mat3(u_model)という手抜きの正しい一般解 = 法線行列も、そこで導出します。