テクスチャ — フィルタ・ラップ・ミップマップ・複数ユニット
第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章の「画像を扱う日が来たら上下反転を思い出す」— これまでに撒いた 伏線が、まとめて回収される章でもあります。
uv-grid.png— 上端が赤帯、下端が青帯、左端が白帯で、右下 1/4 だけ 4px の細かい市松になっています。 読み出し行には、テクスチャに書き込んである設定と、最後に画像を転送したときの条件を 表示しています。カメラは第15章の軌道カメラを簡略化したものです(減衰なし・注視点は原点固定)。この章で学ぶこと:
- テクスチャオブジェクトの生成と
texImage2D。画像は非同期に届くので、間に合わせの 1×1 テクスチャで先に組み立てる UNPACK_FLIP_Y_WEBGLと y 反転(第7章の回収)— 「画像は上の行から、v は下から」というすれ違いの正体TEXTURE_MAG_FILTER(2 種)とTEXTURE_MIN_FILTER(6 種)— 拡大と縮小で別の関数が使われる理由- ミップマップ — なぜ縮小でモアレが出るのか、
generateMipmap、そして 「作らずにミップ系フィルタを指定すると真っ黒」という定番の罠 - ラップ(
REPEAT/CLAMP_TO_EDGE/MIRRORED_REPEAT)— UV が 0〜1 からはみ出したときの決着のつけ方 - テクスチャユニットと
uniform1i(第4章の回収)— sampler に渡すのはテクスチャではなくユニット番号 - GLSL の
sampler2D/texture/texelFetch(第5章の回収)と、 両者の使い分け texImage2D(可変)とtexStorage2D+texSubImage2D(WebGL2 の不変ストレージ)の違い- WebGL2 では非 2 冪 (NPOT) テクスチャの制限が消えたこと
1. テクスチャオブジェクトを作り、画像を送り込む
WebGL のリソースはどれも「作る → バインドする → 操作する」の三拍子でした(第3章の VBO、第13章の VAO と IBO)。テクスチャも同じです。
gl.createTexture()で空のテクスチャオブジェクトを作るgl.bindTexture(gl.TEXTURE_2D, texture)で「いま操作する対象」にするgl.texImage2D(...)で中身(画像)を送り込み、gl.texParameteri(...)で読み方(フィルタ・ラップ)を設定する
引っかかるのは 2 つ目の「バインド」です。VBO のときはgl.ARRAY_BUFFERという1 つの置き場所に差し込むだけでしたが、テクスチャの置き場所は複数あります。これが 6 節で扱うテクスチャユニットで、bindTextureは「いま選ばれているユニット」に対して働きます。この章のコードでbindTextureの前に必ずgl.activeTexture(gl.TEXTURE0)が出てくるのは、そのためです(理由は 6 節でまとめて説明します)。
画像は待ってくれない
JavaScript から画像を読むのは非同期の仕事です。gl.texImage2DにHTMLImageElementを渡すと、テクスチャの幅と高さはその時点のビットマップの寸法になります。 読み込みの終わっていない画像を渡しても意味のある絵にはならないので、まず待ちます。昔ながらのimage.onloadでもよいのですが、HTML 仕様にはdecode()という Promise を返すメソッドがあり、これは「デコードまで完了した」ことを保証してくれます (失敗するとEncodingErrorで reject されます)。
/**
* 画像を読み込む。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を最初に用意し、まだ準備できていないテクスチャの代わりにバインドします。
/**
* 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、高さ1、0(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(いちばん下)に置かれ、上下が入れ替わります。
解決策は 2 つあります。1 つはシェーダー側でvec2(uv.x, 1.0 - uv.y)と読み替えること。もう 1 つが WebGL 専用のピクセル格納パラメータUNPACK_FLIP_Y_WEBGLです。WebGL 1.0 仕様の「Pixel Storage Parameters」に「これを設定すると、以後のtexImage2D/texSubImage2Dでソースデータが縦方向に反転され、概念的には最終行が最初に転送される」と定義されています (初期値はfalse)。転送の時点で並べ替えてしまうので、シェーダーは何も知らなくて済みます。
/** 画像を 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章で頂点の色が補間されたのとまったく 同じ仕組みで、運ばれるのが色ではなく「画像の住所」になっただけです。
// 頂点が持つ UV をそのままフラグメントへ渡す。三角形の内側では
// 3 頂点の UV が補間され、ピクセルごとに違う (u, v) が届く(第3章の色の補間と同じ仕組み)
v_uv = a_uv;3. フィルタ — 拡大と縮小は別問題
テクスチャは有限の格子(テクセル)でできています。256×256 の画像を画面上で 500px 四方に 描けば、1 テクセルが複数ピクセルにまたがります(拡大 / magnification)。 逆に遠くの小さな板に貼れば、1 ピクセルに何十テクセルも詰め込まれます(縮小 / minification)。この 2 つは性質がまったく違うので、WebGL は別々のパラメータを持っています。
TEXTURE_MAG_FILTER—NEARESTかLINEARの 2 択。初期値はLINEARTEXTURE_MIN_FILTER— 上の 2 つにNEAREST_MIPMAP_NEAREST/LINEAR_MIPMAP_NEAREST/NEAREST_MIPMAP_LINEAR/LINEAR_MIPMAP_LINEARを加えた 6 択。初期値はNEAREST_MIPMAP_LINEAR
NEARESTは「いちばん近いテクセルの値をそのまま返す」、LINEARは「近い 4 テクセルの重み付き平均を返す」— OpenGL ES 3.0 のglTexParameterリファレンスは、MIN_FILTERのLINEARをこの言葉で定義しています。MAG_FILTERの項だけは個数を書かず「最も近いテクセルの重み付き平均」ですが、仕様 §3.8.11 が「拡大のLINEARは縮小のLINEARとまったく同じ振る舞い」と定めており、2D テクスチャの補間式(§3.8.10.2)はどちらも 2×2 の 4 テクセルの重み付き和です。デモでNEARESTを選んで思い切り近づくと、色セルの境界がドット絵のようにカクカクになります。LINEARならなめらかにぼけます。どちらが正しいということはなく、ピクセルアートを扱うならNEARESTが正解です。
// 拡大と縮小で使われる関数は別物なので、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)を使うことになります(この章では扱いません)。
/** フィルタとラップはテクスチャオブジェクトの状態。切り替えるたびに書き込み直す */
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 のリファレンスもNEARESTとLINEARの縮小について「1 個ないし数個のテクセルしか見ないので、モアレやギザギザの遷移を生むことがある」と述べています。
根本的な解決は「1 ピクセルが覆う範囲全体の平均を返す」ことですが、毎フレーム 何十テクセルも平均していては話になりません。そこで、あらかじめ縮小した画像を作り置きしておくのがミップマップ (mipmap) です。256×256 の下に 128×128、64×64…と 1×1 まで 9 段を用意し、 描画時に「いま必要な縮小率にいちばん近い段」から読みます。平均はオフラインで済んでいるので、 実行時のコストは 1〜2 回のサンプリングのままです。
MIN_FILTERの 4 種の関係。「段をいくつ使うか(1 つ / 2 つ混ぜる)」×「段の中で最近傍か線形か」の 2 軸で名前ができています。LINEAR_MIPMAP_LINEARは 2 段それぞれで 4 テクセルを平均し、その 2 つをさらに混ぜるのでトライリニアと呼ばれます。名前の読み方さえ分かれば 4 種は一目です。A_MIPMAP_BのAが「選んだ段の中でどう読むか」、Bが「段をどう選ぶか」。BがNEARESTなら縮小率にいちばん近い段を 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(縦)に別々に設定できます。
REPEAT— 小数部だけを見る。1.7 は 0.7 と同じ。画像がタイル状に並びます。WebGL の初期値CLAMP_TO_EDGE— 0〜1 に丸める。1.7 は 1.0 と同じ扱いで、端のテクセルの色が引き伸ばされますMIRRORED_REPEAT— 1 周ごとに鏡像。1.7 は 0.3 の鏡写しになり、タイルの継ぎ目が目立ちません
デモの 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/sampler2D→uniform1i(loc, i)→ テクスチャユニット番号(第16章)」。
sampler2Dに整数を入れる、というのは奇妙に見えます。しかしこれは仕様どおりの唯一の方法で、OpenGL ES 3.0 仕様は「サンプラーの値はUniform1iで設定しなければならない。ほかのUniform*で読み込むことは許されず、INVALID_OPERATIONになる」と明記しています。そして「サンプラーの値を i にすると、テクスチャイメージユニット 番号 i が選ばれる」。
つまりGPU には「テクスチャの差し込み口」が何本も並んでいて、シェーダーの sampler が持つのは口の番号だけです。テクスチャそのものを uniform として渡す API は存在しません。 描画するときの手順は、いつも次の三点セットになります。
gl.activeTexture(gl.TEXTURE0 + i)— これから操作する口を選ぶ(状態機械のスイッチ)gl.bindTexture(gl.TEXTURE_2D, texture)— 選んだ口にテクスチャを差すgl.uniform1i(location, i)— シェーダーの sampler に「あなたは i 番の口を見なさい」と教える
bindTextureでユニットに差さっており、activeTextureはそのとき「どの口をいじっているか」を切り替えるスイッチです。番号は描画中に変わらないので、uniform1iは初期化時に 1 回で済みます。useProgramの後であることだけ守ってください(uniform はプログラムに紐づく状態です。第4章)。
// sampler に渡すのはテクスチャではなくユニット番号。int なので uniform1i(第4章)。
// 番号は描画中に変えないので、ここで 1 回入れれば済む
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0);
gl.uniform1i(gl.getUniformLocation(program, 'u_accentTexture'), 1);毎フレームやるのは、ユニットへの差し替えだけです。
// テクスチャユニットの三点セット。ユニットを選び、そこにテクスチャをバインドし、
// sampler にはユニット番号を入れる(番号は初期化時に入れ済み)
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, accentTexture);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gridTexture);/**
* 設定のためにテクスチャをバインドする。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章の型の節と組み込み関数カタログで名前だけ 紹介したものが、ここで実物になります。
// sampler2D は「テクスチャそのもの」ではなく「テクスチャユニットへの参照」。
// CPU 側は uniform1i(loc, i) でユニット番号 i を入れる(第4章の uniform 表の回収)
uniform sampler2D u_texture;
uniform sampler2D u_accentTexture;// 頂点から来た 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」の定義を要約すると:
- 点サンプリング(フィルタは行われない)
- ラップモードも LOD クランプも効かない
- 読むミップレベルは第 3 引数で明示する
- 座標が範囲外だったり、レベルが存在しなかったりすると結果は未定義
使い分けはこうです。「絵として貼る」なら texture— 拡大縮小がなめらかに処理され、UV の意味も「面のどこか」という比率で自然です。「配列として読む」なら texelFetch— テクスチャを「GPU 上の 2 次元配列」として扱い、i 行 j 列の値をそのまま取りたい場面です。 ルックアップテーブル、パーティクルの状態、そして前フレームの計算結果など、 値がにじんでは困るデータを読むときに使います。
// 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になります。
/** 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.jsがuseTexStorageを立てて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 冪でなければならない」という理由はもうありません。
コード全文
// 第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);#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);
}#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 もWebGLStateの emptyTextures として実在する |
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 = texture | uniform 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 つ間違えると分かりやすく壊れる」ので、実験の効きがよい題材です。
main.tsのuploadGridImageからgl.generateMipmap(gl.TEXTURE_2D);を消す — フィルタを「ミップマップ」にした瞬間、板が真っ黒になります。 不完全なテクスチャをサンプルしたtexture()が (0, 0, 0, 1) を返している姿です。コンソールにはエラーも警告も出ません(4 節)FILTER_MODESの「ミップマップ」のminをgl.LINEAR_MIPMAP_NEARESTにする — ズームすると、段が切り替わる距離でくっきりした帯が現れます。 これを消すために 2 段を混ぜるのがトライリニアだった、という確認ですWRAP_MODESのuvScaleを2.5から8にする — タイルが細かくなり、遠景でのモアレがさらに派手になります。ミップマップの ありがたみが分かりやすくなる設定ですcreateImmutableTextureのgl.texSubImage2D(...)をgl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, image);に変える — 2 枚目が黒いまま合成されなくなります。ブラウザのコンソールにINVALID_OPERATIONの警告が出るはずです。不変ストレージの「不変」を体験する実験です(8 節)uploadGridImageのgl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);を消す — 「y 反転」ボタンが効かなくなります。createImmutableTextureが最後に 0 に戻しているので、反転なしで固定されます。転送設定がコンテキスト全体の状態で あることの実演ですtextures.fragのbaseの行を、コメントアウトしてあるtexelFetchの 2 行に差し替える — 見た目はNEARESTに似ますが、フィルタをLINEARに変えても何も変わりません。フィルタ段を通っていない証拠です(7 節)textures.fragのtexture(u_accentTexture, v_uv)をtexture(u_accentTexture, uv)にする — 2 枚目も倍率の影響を受けてタイル状に並びます。ただし accent 側のラップはCLAMP_TO_EDGEに固定してあるので、タイルではなく引き伸ばしになります。createImmutableTextureのラップをgl.REPEATに変えると並びます- 形状を「球」にして「REPEAT ×2.5」を選ぶ — 継ぎ目(u = 0 と u = 1 が出会う経線)を探してみてください。等倍のときは継ぎ目の先に画像の左端の白帯が来るだけで マスの形は乱れないのに、2.5 倍にするとそこだけタイルの位相が飛びます。第14章の重複頂点が 何をしていたかが逆から見えます
createPlaceholderTextureの色[48, 52, 60, 255]を真っ赤[255, 0, 0, 255]にして、ブラウザの開発者ツールでネットワークを低速に設定して読み込む — 画像が届くまでの 数百ミリ秒だけ赤い板が見えます。プレースホルダが本当に描かれていることの確認です
まとめ
- テクスチャも「作る → バインド → 操作」。ただしバインド先はテクスチャユニットで、
activeTextureが「いまいじる口」を選ぶ。設定や転送の前に必ずユニットを固定する - 画像は非同期。
decode()で待ち、届くまでは 1×1 のテクスチャでしのぐ。1×1 は level 0 だけでミップマップ完備なので、どのフィルタでも安全 - 画像は上の行から、UV の v は下から。
UNPACK_FLIP_Y_WEBGLは転送の設定(コンテキスト全体の状態)で、すでに転送済みのテクスチャには 効かない(第7章の回収) - 拡大は
MAG_FILTER(2 択)、縮小はMIN_FILTER(6 択)。A_MIPMAP_Bの A が段の中の読み方、B が段の選び方。LINEAR_MIPMAP_LINEARがトライリニア MIN_FILTERの初期値はミップマップを要求する。generateMipmapを忘れると「不完全なテクスチャ」になり、texture()は (0, 0, 0, 1) = 黒を返す。エラーは出ない- ラップは
REPEAT(初期値)/CLAMP_TO_EDGE/MIRRORED_REPEAT。UV は 0〜1 に収まる必要がなく、倍率はシェーダーでかけられる - sampler に渡すのはテクスチャではなくユニット番号。仕様上
uniform1i以外で設定するとINVALID_OPERATION(第4章の回収)。Shadertoy のiChannel0〜3もこれと同じ 4 本の sampler(第6章の回収) - 「絵として貼る」なら
texture、「配列として読む」ならtexelFetch(整数座標・フィルタもラップも通らない・レベル指定。第5章の回収) - サイズが分かっているなら
texStorage2D+texSubImage2D(不変ストレージ)。分かる前に オブジェクトが要るならtexImage2D。WebGL2 に非 2 冪の制限はない
面に絵が貼れるようになりました。しかしデモの球は、どこから見ても同じ明るさで、 ボールというより「球形のシール」に見えるはずです。立体を立体らしく見せているのは、 実は模様ではなく陰影だからです。次章第17章「ライティング基礎 — 法線・拡散反射・鏡面反射」では、第14章から持ち歩いていたもう 1 つの属性、法線がついに主役になります。光の向きと法線のdot(第11章の「向きの一致度メーター」)から明るさを作り、この章で貼った テクスチャに陰影を重ねます。mat3(u_model)という手抜きの正しい一般解 = 法線行列も、そこで導出します。