WebGL 2.0 で追加されたテクスチャ配列を使うと、一度のドローコールでたくさんのテクスチャを使用して描画できます。また、記述するシェーダーコードも、通常のテクスチャを複数使用する場合と比べ簡潔に、より柔軟になります

一度のドローコールで使用できるテクスチャは16枚

WebGLにおいて一度のドローコール(描画命令)内で使用できるテクスチャの枚数は何枚でしょうか? 通常、テクスチャマッピングはフラグメントシェーダーで使用します。フラグメントシェーダーで一度に使用できるテクスチャの最大値はgl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)で取得できます。現在実行している環境での値を知りたければ、WebGL Reportの[Fragment Shader] -> [Max Texture Image Units]を見てください。ほとんどの環境では16と表示されると思います。

この値は仕様上、WebGL 1.0 では最低8、WebGL 2.0 では最低16が保証されています。一般的な3Dモデルのレンダリング、例えばディフューズ(拡散反射色)マップとスペキュラ(鏡面反射色)マップと法線マップと……といったように、ひとつのマテリアルの複数のパラメータとしてテクスチャを使用する場合には十分な数です。

しかし、画像のリストなど大量の物体をそれぞれ別のテクスチャを使用してテクスチャマッピングを表示する場合には16枚では心もとなく、テクスチャ配列を使用せずに実現しようとすると複数回のドローコールが必要になります。

テクスチャ配列なら制限を大きく越えてテクスチャを使用可能

WebGL 2.0 で追加されたテクスチャ配列はその名の通りテクスチャを配列のようにまとめて管理できます。前述のMAX_TEXTURE_IMAGE_UNITSは実はテクスチャの直接の枚数ではなく、ユニット数を表しています。

通常の2Dテクスチャは1つのテクスチャユニットごとに1枚のテクスチャしか使用できないため、テクスチャユニット数がそのままテクスチャ枚数と一致し、「16ユニット」のことを「16枚」と表現していました。一方、テクスチャ配列は1つのユニットごとに複数枚のテクスチャを扱えます。この1ユニット内の複数のテクスチャのことをそれぞれレイヤーとよびます。

テクスチャ配列を使用すれば通常のテクスチャの16枚という制限を大きく超え、1つのテクスチャ配列につき最低でも256枚のテクスチャをレイヤーとして配列で管理し、使用できます。

1つのテクスチャ配列ユニットごとに何枚のテクスチャをレイヤーとして使用できるかはgl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS)で取得できます。WebGL Reportでは[Textures] -> [Max Array Texture Layers]がそれにあたります。筆者の環境では2048でした。実行環境ごとのWebGLのパラメータを収集しているWebGL Statsによると、2019年末時点でWebGL 2.0 環境の8割が2048枚のテクスチャ配列をサポートしているとのことです。

サンプルの紹介

テクスチャ配列(TEXTURE_2D_ARRAY)を使ったサンプルを紹介します。

このサンプルでは、それぞれ異なったテクスチャマッピングされた板ポリゴンを複数設置していますが、フレームごとのドローコールはたったの1回です。テクスチャ配列を使用することで、通常のテクスチャの制限である16枚を超えた枚数のテクスチャを一度のドローコールで描画できていることがわかります。

APIの使い方

テクスチャ配列はWebGL 2.0 の機能なので、最初にWebGL 2.0 のコンテキストを取得します。以下gl2は、ここで取得したWebGL2RenderingContextオブジェクトを指します。

// WebGL2コンテキスト(WebGL2RenderingContext)を取得
const gl2 = canvas.getContext('webgl2');

テクスチャ配列の使用手順のうち、大部分は通常の2Dテクスチャと変わりありません。手順2で触れますが、「APIのターゲットにTEXTURE_2Dの代わりにTEXTURE_2D_ARRAYを指定する以外はまったく同じ」という手順もあります。そういった手順の場合タイトルに「(同)」をつけていますので、通常のテクスチャでの手順との違いの目安にしてください。

1. テクスチャを作成する(同)

テクスチャを作成します。こちらはテクスチャ配列でも通常のテクスチャでも一緒です。

// テクスチャを作成
const texture = gl2.createTexture();

2. テクスチャをバインドする(同)

テクスチャに画像データを転送する前に、テクスチャをバインドします。「WebGLのオブジェクトを操作する場合は事前にバインド」というWebGLのルールは WebGL 2.0 でも変わりません。通常のテクスチャはbindTexture()メソッドの第一引数(バインドのターゲット)としてTEXTURE_2Dを使用していましたが、テクスチャ配列ではTEXTURE_2D_ARRAYを使用します。テクスチャをバインドする前にactiveTexture()メソッドを呼んで明示的に0番のテクスチャユニットを指定しています。

// 0番のテクスチャユニットを指定
gl2.activeTexture(gl2.TEXTURE0);

// バインドするターゲットとしてTEXTURE_2D_ARRAYを指定
gl2.bindTexture(gl2.TEXTURE_2D_ARRAY, texture);

3. テクスチャに画像データを転送する

次に、テクスチャに画像データを転送します。通常の2Dテクスチャでは画像データの転送にtexImage2D()メソッドを使用しましたが、テクスチャ配列ではtexImage3D()メソッドを使用します。texImage3D()メソッドは3次元のテクスチャを転送するためのメソッドですが、2次元のテクスチャが複数あるテクスチャ配列も3次元となるので、このメソッドを使用します。

texImage3D()メソッドには、下記の引数を指定します。

引数名 内容
第一引数 target ターゲット(テクスチャ配列を使用する場合は、gl2.TEXTURE_2D_ARRAY
第二引数 level ミップマップレベル(手動で各ミップマップレベルを転送する場合以外は0を指定)
第三引数 internalFormat テクスチャのフォーマット
第四引数 width テクスチャの幅
第五引数 height テクスチャの高さ
第六引数 depth テクスチャの深さ(テクスチャ配列においてはレイヤー数)
第七引数 border ボーダーの幅(WebGLでは固定で0を指定)
第八引数 format テクスチャソースのフォーマット
第九引数 type テクスチャソースのデータタイプ
第十引数 source テクスチャソース

引数が多くて複雑なので、使い慣れていないうちは下記の例を見て幅や高さを変更して使いましょう。

// 転送にはtexImage3D()メソッドを使用する
gl2.texImage3D(
  gl2.TEXTURE_2D_ARRAY, // テクスチャ配列を使用するため固定
  0,                    // ミップマップは後でgenerateMipmap()で作成するため、0固定
  gl2.RGBA,             // テクスチャで使用するフォーマット
  512,                  // 幅512 × 高さ512のテクスチャ
  512,                  // 幅512 × 高さ512のテクスチャ
  50,                   // 50レイヤーのテクスチャ配列
  0,                    // WebGLでは0固定
  gl2.RGBA,             // ピクセルデータのフォーマット
  gl2.UNSIGNED_BYTE,    // Uint8Arrayのピクセルデータを使用するため、UNSIGNED_BYTEを指定する
  pixelData             // ピクセルデータ
);

第四引数width、第五引数heightを見て予想がつくと思いますが、テクスチャ配列内のすべてのテクスチャは同じサイズ(幅、高さ)にする必要があります。異なるサイズのテクスチャを1つのテクスチャ配列で管理することはできません。

第十引数のsourceにテクスチャで使用する画像を指定しますが、ここに少し注意点があります。指定する画像データとしてtexImage2D()メソッドと同じく、Imageオブジェクト(HTMLImageElement)やキャンバス(HTMLCanvasElement)を指定できますが、このとき、テクスチャ配列内の複数の画像データが縦に並んでいる必要があります。上記の画像サイズの例だと、画像の高さ1〜512pxまでが1枚目の画像、513〜1024pxまでが2枚目の画像……というように、縦に50枚の画像を並べ、都合、幅512px × 高さ32768pxの大きさの画像データを入力として指定します。

この「縦に並べてひとつにした画像データ」は、あらかじめ1枚の大きな画像としてオフラインで用意してサーバーに設置しておいてもいいですし、ランタイムで作成しても問題ありません。ランタイムで作成するほうが任意の画像の組み合わせをテクスチャ配列にできるため、柔軟性がありそうです。

今回のサンプルではsourceとして、ピクセルの画素データ配列(Uint8Array)を指定する形にしました。画像を1枚ずつdrawImage()メソッドでキャンバスに描画し、getImageData()メソッドでキャンバスから取得した画素データをつなげてひとつの大きな画素データ配列(Uint8Array)にしています。大きなキャンバスにしてもよかったのですが、キャンバスのサイズにはブラウザごとの制限があるので、キャンバスのサイズは画像1枚のサイズに固定し、取得した画素データをつなげるようにしました。

// テクスチャ1枚の要素数
// 幅 * 高さ * 4要素(RGBA)
const elementsPerTexture = IMAGE_WIDTH * IMAGE_HEIGHT * 4;

// テクスチャ配列用のUint8Arrayを確保
// 要素数: テクスチャ1枚の要素数 * テクスチャ数
const pixelData = new Uint8Array(elementsPerTexture * NUM_TEXTURES);

for (let i = 0; i < NUM_TEXTURES; i++) {
  // テクスチャ画像の読み込み
  const textureImage = await loadImage(`assets/${i}.jpg`);
  // テクスチャ画像をキャンバスに描画
  context2d.drawImage(textureImage, 0, 0);
  // RGBA値の取得
  const imageData = context2d.getImageData(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
  // RGBA値のセット
  // i番目のテクスチャなので、テクスチャi枚の要素数をオフセットに指定
  pixelData.set(imageData.data, elementsPerTexture * i);
}

4. テクスチャパラメータを設定する(同)

フィルタリング、ラッピングなどのテクスチャパラメータを設定します。ここでもターゲットがTEXTURE_2D_ARRAYであることを除けば通常のテクスチャと使用方法は変わりません。テクスチャパラメータはバインドされているテクスチャ配列に対して設定するため、テクスチャのサイズと同様にテクスチャ配列内のすべてのテクスチャは同じテクスチャパラメータになります

// テクスチャパラメータを設定
gl2.texParameteri(gl2.TEXTURE_2D_ARRAY, gl2.TEXTURE_MAG_FILTER, gl2.NEAREST);
gl2.texParameteri(gl2.TEXTURE_2D_ARRAY, gl2.TEXTURE_MIN_FILTER, gl2.LINEAR_MIPMAP_LINEAR);
gl2.texParameteri(gl2.TEXTURE_2D_ARRAY, gl2.TEXTURE_WRAP_S, gl2.CLAMP_TO_EDGE);
gl2.texParameteri(gl2.TEXTURE_2D_ARRAY, gl2.TEXTURE_WRAP_T, gl2.CLAMP_TO_EDGE);

5. ミップマップを作成する(同)

TEXTURE_MIN_FILTERテクスチャパラメータにミップマップを使用する設定をした場合は、ミップマップの作成が必要となります。WebGLでは、バインドされているテクスチャのミップマップを自動で作成してくれる便利な機能generateMipmap()メソッドがあるのでこれを使用します。手動で作成する場合はtexImage3D()メソッドの第二引数にレベルを指定して呼び出します。ミップマップに関しても通常のテクスチャでの使用方法となんら変わりはありません(もちろんターゲットはTEXTURE_2D_ARRAYです)。

// ミップマップを作成
gl2.generateMipmap(gl2.TEXTURE_2D_ARRAY);

6. シェーダーでテクスチャ配列をサンプリングする

シェーダーでテクスチャ配列をサンプリングするコードは下記のとおりです。まず、WebGL 2.0 のお約束、シェーダーの1行目に#version 300 esバージョンディレクティブを指定します。

#version 300 es
precision mediump float;

// sampler2DArrayの精度を指定
precision mediump sampler2DArray;

in vec2 vUv;
in float vTextureIndex;
out vec4 outColor;

// sampler2D型の代わりにsampler2DArray型を使用
uniform sampler2DArray textureArray;

void main(void)
{
  // texture()関数の第一引数にsampler2DArrayを指定し、第二引数にvec3(u, v, layer)を指定する
  outColor = texture(textureArray, vec3(vUv, vTextureIndex));
}

テクスチャサンプラーのuniformの型は、通常の2Dテクスチャではsampler2Dを使用していましたが、テクスチャ配列ではsampler2DArrayを使用します。sampler2DArray型はデフォルトでは精度の指定がされていませんので、使用する場合は精度の指定が必要です。下記にsampler2DArrayの宣言部分を抜き出しました。

// sampler2DArrayの精度を指定
precision mediump sampler2DArray;

// sampler2D型の代わりにsampler2DArray型を使用
uniform sampler2DArray textureArray;

WebGL 1.0 のシェーダー(GLSL ES 1.0) ではテクスチャのサンプリングにtexture2D()関数を使用していましたが、WebGL 2.0 のシェーダー(GLSL ES 3.0) ではテクスチャのサンプリングにtexture()関数を使用します。このtexture()関数の引数は、通常の2Dテクスチャかテクスチャ配列かで型が下記のように異なります。

通常の2Dテクスチャ テクスチャ配列
第一引数 sampler2D sampler2DArray
第二引数 vec2(u, v) vec3(u, v, layer)

テクスチャ配列の場合、第二引数にはテクスチャのUV座標の他に、レイヤー番号で配列の何番目のテクスチャかを指定します。レイヤー番号はvec3.zで指定するためfloatですが、0.5を境に切り捨てもしくは切り上げられ、整数値として扱われます。

たとえばvec3(0.4, 0.6, 3.4)を指定した場合は配列の3番目のテクスチャのuv = (0.4, 0.6)のテクセルを取得し、vec3(0.4, 0.6, 3.5)を指定した場合は配列の4番目のテクスチャのuv = (0.4, 0.6)のテクセルを取得します。小数点の値を指定したからといって2枚のテクスチャが補完されてブレンドされるわけではありません。配列内のいずれか1枚のテクスチャが選択されます

7. JavaScriptからuniformをセットする(同)

シェーダーで使用するsampler2DArray型のuniformに対応したロケーションをgetUniformLocation()メソッドで取得します。手順2で0番のテクスチャユニットを指定したので、このuniformロケーションにuniform1i()メソッドで0を割り当てます。こちらも通常のテクスチャと手順が変わりありません。

// テクスチャのuniform locationをシェーダーから取得する
const textureLocation = gl2.getUniformLocation(program, 'textureArray');

// テクスチャ配列のuniform locationに0番のテクスチャユニットを指定
gl2.uniform1i(textureLocation, 0);

以上でテクスチャ配列をサンプリングしてレンダリングできます。通常のテクスチャと比較して大きく異なるのは以下の3点です。

  • TEXTURE_2Dの代わりにTEXTURE_2D_ARRAYを使うこと
  • 画像データの転送にtexImage3D()メソッドを使うこと
  • シェーダーでのサンプラーの型が違うことと、texture()関数の第二引数がvec3になること

テクスチャ配列は使用するテクスチャを動的に指定できる

テクスチャ配列を使うと大量のテクスチャを一度にレンダリングできることがわかりました。テクスチャ配列にはもうひとつ、コード上の利点があります。それは、配列内のどのテクスチャを使用するかを動的に指定できることです。当然のように思うかもしれませんが、これはGLSLの文法上、たいへんありがたいことです。

GLSLでは配列が定義できるため、テクスチャ配列を使わなくても以下のように(ややこしいですが)「通常の2Dテクスチャの配列」を定義することはできます。

#version 300 es
precision mediump float;

in vec2 vUv;
out vec4 outColor;

// sampler2Dの配列
uniform sampler2D textureList[16];

void main(void)
{
  // 5番目のテクスチャをサンプリング
  outColor = texture(textureList[5], vUv);
}

しかし、これには大きな制約があり、textureList[index]で指定する配列アクセスのインデックスは5などの定数値である必要があります。シェーダー内で算出した値や頂点シェーダーからの補間値、はたまたuniform経由での指定ですら許されません。

#version 300 es
precision mediump float;

// varyingでのインデックス指定
flat in uint varyingTextureIndex;

in vec2 vUv;
out vec4 outColor;

// uniformでのインデックス指定
uniform uint uniformTextureIndex;

// sampler2Dの配列
uniform sampler2D textureList[16];

void main(void)
{
  // シェーダー内で算出したインデックス指定
  uint computedTextureIndex = uint(floor(vUv.x * 4.0) + floor(vUv.y * 4.0) * 4.0);
  
  outColor = texture(textureList[5], vUv); // OK
  outColor = texture(textureList[varyingTextureIndex], vUv); // コンパイルエラー
  outColor = texture(textureList[uniformTextureIndex], vUv); // コンパイルエラー
  outColor = texture(textureList[computedTextureIndex], vUv); // コンパイルエラー
}

そのため、「通常の2Dテクスチャの配列」で動的なインデックスで使用するテクスチャを選択したい場合は、下記のようにかなり冗長な書き方をする必要がありました。

void main(void)
{
  uint textureIndex = uint(floor(vUv.x * 4.0) + floor(vUv.y * 4.0) * 4.0);
  vec2 uv = vUv * 4.0;
  vec4 color;
  if (textureIndex == 0u){
    color = texture(textureList[0], uv);
  }
  else if (textureIndex == 1u){
    color = texture(textureList[1], uv);
  }
  else if (textureIndex == 2u){
    color = texture(textureList[2], uv);
  }
  // ...
  // 省略
  // ...
  else if (textureIndex == 14u){
    color = texture(textureList[14], uv);
  }
  else if (textureIndex == 15u){
    color = texture(textureList[15], uv);
  }
  outColor = color;
}

これが、テクスチャ配列を使うとこんなにスッキリ書けます。

#version 300 es
precision mediump float;
precision mediump sampler2DArray;

in vec2 vUv;
out vec4 outColor;

uniform sampler2DArray textureArray;

void main(void)
{
  uint textureIndex = uint(floor(vUv.x * 4.0) + floor(vUv.y * 4.0) * 4.0);
  outColor = texture(textureArray, vec3(vUv * 4.0, textureIndex)); // 動的なインデックスを指定できる!
}

複数のテクスチャを使用するときにどのテクスチャを選択するか動的に判断したいことはままあると思いますが、そういったケースでもテクスチャ配列の力が発揮できます。

TIPS

画像の非同期転送にはtexSubImage3D()メソッド

今回紹介したtexImage3D()メソッドで画像を転送する方法だと、テクスチャ配列で使用するすべての画像データを一度に指定しなくてはなりません。大量のテクスチャを使用する場合にはすべての画像のWEB読み込みを待つと、テクスチャ配列が使用できるようになるまで時間がかかります。

texSubImage3D()メソッドは、フォーマット定義済みのテクスチャ配列に対してオフセット(uv座標やレイヤーの途中開始位置)を指定して画像データの一部を転送できます。texImage3D()メソッドの第十引数のsourcenullを指定してフォーマットのみ定義しておいて、画像をWEB読み込みしつつ順次texSubImage3D()メソッドを使用して転送すれば、テクスチャ配列の非同期読み込みが実現できます。

ただし、この方法には注意が必要です。ミップマップの作成をする場合、generateMipmap()メソッドは呼び出し時点で転送済みのテクスチャ配列全体に適用されます。非同期読み込みしている途中のテクスチャ配列をテクスチャマッピングで表示する場合は、texSubImage3D()メソッドで画像を転送する都度generateMipmap()メソッドでミップマップの作成をする必要があります

テクスチャのフォーマット定義はtexStorage3D()メソッドのほうが良い?

WebGL 2.0 の仕様では、texImage2D()の代わりにtexStorage2D()を使用するように推奨されています(※2)。これは、一部のWebGL環境でメモリコストが少なく済む可能性があるためとのことです。texStorage2D()はWebGL 2.0 で追加されたメソッドで、イミュータブルなテクスチャを定義(確保)します。

※2 [WebGL 2.0 Specification] -> [3.7.6 Texture objects] -> [void texStorage2D]

「イミュータブルなテクスチャ」は、こちらもWebGL 2.0 で使用できるようになった機能で、一度指定したフォーマット(サイズや画像フォーマット)を変更できないテクスチャです。texStorage2D()を呼び出したテクスチャに対し、再度texStorage2D()texImage2D()を呼び出すことはできません。

同様に3次元のテクスチャに関しても、texImage3D()よりtexStorage3D()が推奨されています(※3)。メモリ効率だけでなくイミュータブルであることの堅牢性を考えても、テクスチャ配列のフォーマット定義にはtexImage3D()メソッドではなくtexStorage3D()メソッドを使用したほうが良いでしょう。

※3 [WebGL 2.0 Specification] -> [3.7.6 Texture objects] -> [void texImage3D]

WebGL Insightsの「1.4.2 Beyond WebGL: Recommendations for OpenGL ES, WebGL 2, and More」では、ANGLE環境ではtexStorage*を使用してイミュータブルなテクスチャを確保するよう推奨しています

2019年3月に公開されたWebGL best practicesでは、ドライバによってはtexImage*を使ってテクスチャを確保すると、ミップマップを使用しない場合でもミップマップを使用した場合と同等のメモリ(+30%)が確保される場合がある、という観点でtexStorage*を推奨しています

TIPSをふまえてテクスチャ配列の確保にtexStorage3D()メソッドを使用し、画像を非同期読み込みして都度texSubImage3D()メソッドで転送するよう変更したサンプルはこちらです。

リファレンス

TEXTURE_2D_ARRAYで使用するテクスチャ転送のメソッドについて、詳しくは下記のドキュメントを参照ください。