はじめに (対象読者・この記事でわかること)

この記事は、three.jsでカスタムシェーダーを使った表現に挑戦している方、特にShaderMaterialのフラグメントシェーダーで配列の扱いに戸惑っている方を対象としています。一般的なプログラミング言語の感覚で配列に変数でアクセスしようとして、「あれ?動かないぞ?」と疑問に感じた経験があるかもしれません。

この記事を読むことで、three.jsのShaderMaterialにおけるGLSL(OpenGL Shading Language)の配列アクセスに関する基本的な制約を理解し、その制約を回避して意図した表現を実現するための具体的な方法(ループアンロール、テクスチャルックアップ、条件分岐など)がわかります。GPUがどのように処理を実行しているかという根本的な理解も深まるでしょう。

私自身もthree.jsで複雑なエフェクトを実装しようとした際に、この配列の制約に直面し、なぜこのような挙動になるのか悩んだ経験があります。この記事が、同じ疑問を持つ方々の一助となれば幸いです。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 * JavaScriptの基本的な知識 * three.jsの基本的な使い方(シーン、カメラ、メッシュ、マテリアルなど) * GLSLの初歩的な知識(uniform、attribute、varyingといった変数修飾子、基本的な組み込み関数など)

GLSLにおける配列アクセスの基本的な考え方と制限

three.jsのShaderMaterialは、WebGLの低レベルなシェーダー記述言語であるGLSL(OpenGL Shading Language)を直接記述できる強力な機能です。これにより、three.jsが提供する標準マテリアルでは実現できない、独自の視覚表現や計算処理をGPU上で実行することができます。

GPUは、数千ものプロセッサコアを持つ並列処理に特化したアーキテクチャです。フラグメントシェーダーは、画面上のピクセル一つ一つに対して並行して処理を行うため、その効率性が非常に重要になります。GLSLは、このGPUの特性を最大限に引き出すために設計された言語であり、通常のCPU向け言語とは異なる厳格な制約を持っています。

その中でも特に開発者が戸惑いやすいのが「配列の添字に変数が使えない」という制約です。例えばJavaScriptであれば、myArray[i] のようにiが実行時に変化する変数であっても問題なくアクセスできます。しかし、GLSLの特に古いバージョン(WebGL 1.0 / GLSL ES 1.0)では、フラグメントシェーダー内の配列へのアクセスは、コンパイル時にそのアクセスパターンが完全に特定できる「定数式(constant expression)」でなければならないという厳しいルールがあります。

これはなぜでしょうか? GPUは極めて高速に大量のピクセルを処理するため、シェーダーのコードはコンパイル時に徹底的に最適化されます。もし配列のインデックスが実行時までわからない動的な値であった場合、GPUはどのメモリ位置にアクセスすべきかを事前に予測・最適化することができません。これにより、並列処理の効率が著しく低下する可能性があるため、GLSLはこのような動的な配列アクセスを制限しているのです。これにより、コンパイル時にコードパスが確定し、GPUが効率的に命令をパイプライン化できるようになります。

three.jsがデフォルトで使用するWebGL 1.0はGLSL ES 1.0に準拠しており、この制約が厳しく適用されます。この制限があるために、「配列の特定要素に、ある条件に基づいてアクセスしたい」といった一般的なプログラミングで行うような処理が、GLSLでは一筋縄ではいかない状況が生まれるわけです。

GLSLの配列インデックス制約を乗り越える実践テクニック

前述の通り、GLSL(特にWebGL 1.0 / GLSL ES 1.0)では、配列のインデックスに変数を直接使用することはできません。しかし、この制約があるからといって、複雑なシェーダー表現が不可能になるわけではありません。ここでは、この制約を賢く回避し、意図した表現を実現するための具体的なテクニックをいくつか紹介します。

GLSLの配列インデックス制約の深掘り

まず、具体的に「定数式」とは何かを理解しましょう。GLSLにおける定数式とは、コンパイル時に値が確定する式のことです。これには、リテラル値(例: 0, 1, 3.14)や、const修飾子で宣言された変数、そしてこれらのみで構成される簡単な算術式などが含まれます。一方、uniform変数、varying変数、またはforループのカウンタ変数など、実行時に値が変化する可能性のある変数は「動的な値」とみなされ、配列のインデックスとしては使用できません。

この制約は、特にピクセルごとの条件に基づいて異なるデータを配列から選択したい場合に大きな壁となります。しかし、以下の方法でこの壁を乗り越えることができます。

解決策1: ループアンロール (Loop Unrolling)

配列の要素数が少なく、かつ固定である場合、ループを手動で展開(アンロール)することで、動的なインデックスアクセスを回避できます。これは最も単純な解決策ですが、配列のサイズが大きくなるとコードが冗長になります。

メリット: * 理解しやすく、実装が容易。 * 非常に短い配列の場合、パフォーマンスのオーバーヘッドが少ない。

デメリット: * 配列の要素数が増えると、シェーダーコードが非常に長くなる。 * 配列の要素数が動的に変化する場合には適用できない。 * コードのメンテナンス性が低下する。

GLSLコード例:

Glsl
// myArrayはuniform float myArray[3]; のように定義されていると仮定 void main() { float result; // indexが0, 1, 2のいずれかであることが分かっている場合 if (index == 0.0) { // indexはfloat型で渡されることが多い result = myArray[0]; } else if (index == 1.0) { result = myArray[1]; } else if (index == 2.0) { result = myArray[2]; } else { result = 0.0; // fallback } gl_FragColor = vec4(result, result, result, 1.0); }

解決策2: 条件分岐 (If/Else) を利用した疑似アクセス

これもループアンロールに近い考え方ですが、if/else if文を使って、渡されたインデックス値に応じて適切な配列要素にアクセスする方法です。これも要素数が少ない場合に有効です。

メリット: * 配列の要素数が少ない場合、比較的コードが読みやすい。

デメリット: * 条件分岐の数が増えると、シェーダーのコンパイルが複雑になり、パフォーマンスが低下する可能性がある。 * 大量の要素を持つ配列には不向き。

GLSLコード例:

Glsl
uniform float u_dataArray[5]; // 例:5要素のuniform配列 uniform float u_selectedIndex; // 外部から渡される選択インデックス void main() { float selectedValue = 0.0; // u_selectedIndexが整数値であることを前提とする if (u_selectedIndex < 0.5) { // u_selectedIndexが0に近い場合 selectedValue = u_dataArray[0]; } else if (u_selectedIndex < 1.5) { // u_selectedIndexが1に近い場合 selectedValue = u_dataArray[1]; } else if (u_selectedIndex < 2.5) { selectedValue = u_dataArray[2]; } else if (u_selectedIndex < 3.5) { selectedValue = u_dataArray[3]; } else if (u_selectedIndex < 4.5) { selectedValue = u_dataArray[4]; } gl_FragColor = vec4(selectedValue, selectedValue, selectedValue, 1.0); }

u_selectedIndexは通常float型で渡されるため、int()キャストするか、floor()関数やround()関数で整数化し、比較もfloatで行う方が安全です。

解決策3: テクスチャを用いたデータ参照 (Texture Lookup)

これが最も強力で柔軟な解決策であり、GLSLで大量の動的なデータを扱う際の標準的な方法です。配列データを「テクスチャ」(画像データ)としてGPUに渡し、フラグメントシェーダー内でtexture2D関数を使ってピクセルデータとして参照します。

画像は本質的に2次元の配列であり、GPUはテクスチャからのデータフェッチ(参照)を極めて高速に行うように最適化されています。そのため、任意のインデックス(ピクセル座標)からデータを取得する操作は、GPUにとって非常に効率的です。

メリット: * 非常に大量のデータを効率的に扱える。 * 配列の要素数を柔軟に変更できる(テクスチャサイズによる)。 * GLSLの動的インデックスアクセス制限を完全に回避できる。

デメリット: * JavaScript側でのデータ準備(DataTextureなど)が少し複雑になる。 * データの型がテクスチャのピクセルフォーマットに制約される(通常はvec4)。 * テクスチャのUV座標を計算する必要がある。

JavaScript側でのデータ準備例:

Javascript
import * as THREE from 'three'; // 例:10個のfloat値をシェーダーに渡したい場合 const data = new Float32Array(10 * 4); // RGBA形式なので要素数の4倍の長さが必要 (10個のvec4データ) for (let i = 0; i < 10; i++) { // 例として、i番目のデータにi * 0.1 を格納 data[i * 4 + 0] = i * 0.1; // Rチャンネルにデータを格納 data[i * 4 + 1] = 0; // Gチャンネル data[i * 4 + 2] = 0; // Bチャンネル data[i * 4 + 3] = 1; // Aチャンネル } // 10x1ピクセルのテクスチャとしてデータを格納 // THREE.RGBAFormatとTHREE.FloatTypeはWebGL 1.0でFloatTexture拡張が必要な場合がある // WebGL 2.0であればより広範に利用可能 const dataTexture = new THREE.DataTexture( data, 10, // width 1, // height THREE.RGBAFormat, THREE.FloatType // floatデータを格納する場合 ); dataTexture.needsUpdate = true; // テクスチャの更新をthree.jsに通知 // ShaderMaterialのuniformとしてテクスチャを渡す const uniforms = { u_dataTexture: { value: dataTexture }, u_dataTextureSize: { value: new THREE.Vector2(10, 1) } // テクスチャのサイズも渡す }; const material = new THREE.ShaderMaterial({ uniforms: uniforms, vertexShader: ` void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D u_dataTexture; uniform vec2 u_dataTextureSize; uniform float u_lookupIndex; // 参照したいインデックス(0〜9) void main() { // インデックスをUV座標に変換 // ピクセル中央を参照するためには (index + 0.5) / width を使う float u = (u_lookupIndex + 0.5) / u_dataTextureSize.x; float v = 0.5; // 1xNのテクスチャの場合、Y座標は常に0.5 (中央) // テクスチャからデータを参照 vec4 fetchedData = texture2D(u_dataTexture, vec2(u, v)); float value = fetchedData.r; // Rチャンネルから値を取得 gl_FragColor = vec4(value, value, value, 1.0); } ` });

解決策4: uniform配列の活用 (WebGL 2.0 / GLSL ES 3.0向け)

もしthree.jsでWebGL 2.0 (GLSL ES 3.0) を使用できる環境であれば、uniform配列に関しては動的なインデックスアクセスが可能になります。uniform変数はJavaScriptからシェーダーに値を渡すための変数です。

WebGL 2.0の有効化 (JavaScript側): three.jsのレンダラーを初期化する際に、WebGLRendererのオプションでpowerPreference: 'high-performance'と共にalpha: false(通常は不要だが、パフォーマンスのため)やantialias: trueなどと共に、明示的にWebGL2RenderingContextを要求する設定は通常は不要です。three.jsは利用可能な最も高性能なレンダリングコンテキスト(WebGL 2.0を優先)を自動的に選択しようとします。しかし、もし明示的にWebGL 2.0機能を使用したい場合は、以下のように記述できます。

Javascript
const renderer = new THREE.WebGLRenderer({ antialias: true, canvas: myCanvas, context: canvas.getContext('webgl2') }); // または、three.jsが自動的にWebGL2を選択しない場合、capabilitiesをチェックする // if (renderer.capabilities.isWebGL2) { /* WebGL2固有の処理 */ }

GLSL ES 3.0コード例 (フラグメントシェーダー):

Glsl
#version 300 es // GLSL ES 3.0を指定 precision highp float; uniform float u_myArray[10]; uniform int u_dynamicIndex; // int型のuniformもOK out vec4 FragColor; // gl_FragColorの代わりにout変数を使用 void main() { // uniform配列への動的なアクセスが可能 float value = u_myArray[u_dynamicIndex]; FragColor = vec4(value, value, value, 1.0); }

ただし、この緩和はuniform配列に限定されます。varying変数やローカル配列への動的アクセスは、GLSL ES 3.0でも依然として制約がある場合が多い点に注意が必要です。また、古いデバイスやブラウザではWebGL 2.0がサポートされていない可能性も考慮する必要があります。

ハマった点やエラー解決

GLSLで配列の添字に変数が使えないという問題に直面したとき、よく目にするエラーメッセージは以下のいずれかです。

  • error: array index must be a constant expression
  • error: array index must be a constant integer expression

これらのエラーは、まさにこの制約に引っかかっていることを示しています。JavaScriptの感覚でmyArray[i]と書いてしまうと、iが定数式ではないためコンパイルエラーとなります。

この問題は、GLSLの設計思想、つまりGPUの並列処理に最適化された静的なコードパスの要求に起因します。GPUは、シェーダーが実行される前に、全てのコードパスとメモリアクセスパターンを完全に把握しておくことで、効率的なパイプライン処理を実現します。動的なインデックスは、この静的解析を妨げるため、制限されるのです。

デバッグの際には、エラーメッセージが出た行の配列アクセスが本当に「定数」でなされているかを確認し、もし動的なインデックスを使いたかったのであれば、上記で説明した「ループアンロール」「条件分岐」「テクスチャルックアップ」などの回避策を検討する必要があります。特にテクスチャルックアップは、汎用性が高く多くの場面で利用できるため、優先的に学習することをお勧めします。

解決策

上記で提示した解決策の中から、データ量と要件に応じて最適なものを選択します。

  • 少量の固定データで、パフォーマンスへの影響が許容範囲であれば、ループアンロールまたは条件分岐
  • 大量のデータや、動的にデータが変化する可能性のある場合、または最も汎用的な解決策を求めるなら、テクスチャルックアップ
  • WebGL 2.0環境で、かつuniform変数として配列を渡したい場合は、#version 300 esを指定してGLSL ES 3.0のuniform配列動的アクセスを利用する。

ほとんどの場合、テクスチャを用いたデータ参照が最も推奨されるアプローチです。この方法をマスターすることで、three.jsのShaderMaterialを使った表現の幅が格段に広がります。

まとめ

本記事では、three.jsのShaderMaterialにおけるGLSLの配列インデックスに関する疑問と、その解決策について解説しました。

  • GLSLの配列インデックス制約:GPUの並列処理とコンパイル時最適化のため、配列の添字には基本的に定数式が必要。動的な変数でのアクセスは直接できない。
  • 解決策の選択肢:少量の固定データにはループアンロールや条件分岐が有効。大量の動的データや汎用性が必要な場合は、テクスチャを用いたデータ参照が最も強力な解決策となる。
  • WebGL 2.0の恩恵:GLSL ES 3.0では、uniform配列に限って動的なインデックスアクセスが可能になるが、環境によるサポート状況に注意が必要。

この記事を通して、GLSLの配列に関する制約を理解し、より高度なシェーダー表現を実現するための具体的な手段を習得できたはずです。この知識は、カスタムポストエフェクトや複雑な視覚エフェクトの実装において強力な武器となるでしょう。

今後は、ShaderMaterialのパフォーマンス最適化や、さらに複雑なカスタムポストエフェクトの実装方法などについても記事にする予定です。

参考資料