範囲外指定エラー
はみ出しちゃった

フェッチする際にバイト長とサイズに従って数を作るわけですが、バイト列は有限ですから、変な指定の仕方をするとはみ出しが起きてしまいます。その位置にバイトデータは存在しないので、困ったことになります。しかもその場合の挙動が例によって機種依存なのでますます困るわけです。それについて触れましょう。
そして、そのような事態は変なフェッチをしなくても起こりうるということについても触れます。割とホラーな話になるかと思います。激闘の歴史でもあり、虚しい記憶でもあります。
コード全文
プログラムの再利用
とりあえずはみ出すとどうなるのか見てみたいので、前回のコードを再利用してまずは露骨に用意しました。状況を作らないことには始まらないので。strideとoffsetをいじれば簡単です。色指定がはみ出すようにできます。この通り:
const buf = gl.createBuffer(); // 位置と色で使う
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, pData, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// stride:9, offset:5
// 5,6,7, 14,15,16, 23,24,25(はみだし)
gl.vertexAttribPointer(1, 3, gl.UNSIGNED_BYTE, true, 9, 5);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
前回の頂点の位置データで色を用意するところで、strideを9としoffsetを5としています。こうするとフェッチ位置が5,6,7, 14,15,16, 23,24,25になります。24と25は存在しないのでフェッチできません。意地悪だなぁ!プログラムを困らせるのは楽しいものです。
それでどうなるかというと、まあ走らせてみましょう。
範囲外指定時の挙動
はみ出した場合の挙動はブラウザにより異なるんです。それを見ます。
Chrome系の場合(Edgeなど)
サムネイルを再掲しますが、この場合3つ目の色は「黒」になります。そういうわけでこうなります:

なお、スマホでも確認したいのでコンソールを可視化しました。p5時代はこういうのをキャンバス描画でやっていたんですが、それだとテキストデータのコピペとかできないんで不便すぎるんですよね...DOMでやるべきでしょう。で、こんな風:
errorCode:0
gl.NO_ERROR: 0
gl.INVALID_ENUM: 1280
gl.INVALID_VALUE: 1281
gl.INVALID_OPERATION: 1282
NO_ERRORです。これはどう取得しているかというと、WebGLのgetErrorという関数を使っています。
const pTag = document.getElementById("error");
pTag.innerText = ``;
pTag.innerText += `errorCode:${gl.getError()}\n`;
pTag.innerText += `gl.NO_ERROR: ${gl.NO_ERROR}\n`;
pTag.innerText += `gl.INVALID_ENUM: ${gl.INVALID_ENUM}\n`;
pTag.innerText += `gl.INVALID_VALUE: ${gl.INVALID_VALUE}\n`;
pTag.innerText += `gl.INVALID_OPERATION: ${gl.INVALID_OPERATION}\n`;
関数紹介で触れると思いますが、getErrorは直前に発生したドローコール関連のエラーを取得する関数です。コードは数値で得られるんで、参考に色々付記しました。0なので「NO_ERROR」です。さて...
Firefox系の場合
私が愛用しているWaterfoxなどのFirefox系ではこうです:

何も表示されません。いわゆる「描画不履行」です。フェッチできないので、まあ何にも描画しなくていいだろうな、ってわけです。まあそうですよね。仕事で不明瞭な命令を出されたら、不用意に自己判断で勝手なことをしないのはトラブルを防ぐうえでの基本姿勢です。勝手に判断して行動するようなことは許されません。どんな事故が起きるかわかったもんじゃない。...これはWebGLの記事です。
それでブラウザに依るんですが、私のAndroidでも同じ挙動なので、真に機種依存です。コンソール:
errorCode:1282
gl.NO_ERROR: 0
gl.INVALID_ENUM: 1280
gl.INVALID_VALUE: 1281
gl.INVALID_OPERATION: 1282
見ての通り、「1282」ですから、INVALID_OPERATIONです。何がNO_ERRORだよ。異常起きてるじゃねぇか。これだから勝手に判断して行動する派遣は...これはWebGLの記事です。なのでまずいわけです。
WebGL Specificationを読む
これらの挙動についてはkhronosのサイトのここに書いてあります:
6.6 Enabled Vertex Attributes and Range Checking
- The WebGL implementation may generate an INVALID_OPERATION error and draw no geometry.
- Out-of-range vertex fetches may return any of the following values:
- Values from anywhere within the buffer object.
- Zero values, or (0,0,0,x) vectors for vector reads where x is a valid value represented in the type of the vector components and may be any of:
- 0, 1, or the maximum representable positive integer value, for signed or unsigned integer components
- 0.0 or 1.0, for floating-point components
draw no geometryとあると思いますがこれが一番上に来ます。mdnの記事を書いてるのはMozillaなのでFirefox系の挙動が正式なわけです。それで、勝手に黒とかで埋めちゃうのが2ですね。そういうわけで、機種に依存します。
これを防ぐには、そもそもフェッチが起きないようにすればいいです。lilでいじれるようにしてあるのが分かるかと思いますが、スロットを非有効化すればいいんですよね。つまり色のフェッチを禁止します。
gl.useProgram(pg);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
if(config.disable){
gl.disableVertexAttribArray(1);
}
今回はドローコール時に両方有効化して、disableオプションの時に1だけ閉じています。こうすることで、環境に依らず同じ結果になります:

いずれの場合も正しく「NO_ERROR」です。シャッターを閉じればいいんですね。
こんな感じで、意外とそういう挙動については記述があるんですが、全編英語だし、なによりどこを読めばいいのか分かんないので、この記事のような親切なガイドでもなければ活用するのは難しいと思います...ただ、暇があったら熟読してみると良いかと思います。ちなみに自分はやったことが無いです。
これでおわり?
さて、ここで記事が終わりだと、まるで対岸の火事ですね。そんな変なフェッチしなければいい。そうですね。しかし状況によっては、普通にコードを書いてても起きます。なんならstrideとoffsetが共に0でも起きるよってのを、理解してもらうために、この記事はもうちょっとだけ続きます。
自然に起きるケース

予告通り、意図しない形でこのエラーが出る場合についてみます。最初に線を引いて正方形を描きます。その中にこういう感じの八角形を描くわけです。0番は頂点データの置き場所として共用になるので、あとからデータを上書きしています(完全上書き)。しかしこの八角形は事情によりアトリビュートではなくuniformで描画したいので1番スロットは使いません。しかし使うのは0番だけですから0番だけ有効化します。なんかおかしいですね。まあとにかく動かしてみましょうか。
コード全文
アトリビュートの用意
今回用意するのは色のついた線と、八角形です。八角形は、アトリビュートではなくuniformで色を付けたいので、色のアトリビュートは用意しません。まあ同じシェーダーでいろんな彩色方法を併用するのはよくあることですから。
const pLine = new Float32Array([-0.75, -0.75, 0.75, -0.75, 0.75, 0.75, -0.75, 0.75]);
const cLine = new Uint8Array([255,255,255, 255,255,0, 255,0,255, 0,255,255]);
const pPolygon = new Float32Array([
0, 0,
-0.5, 0, -0.75, -0.75, 0, -0.5, 0.75, -0.75,
0.5, 0, 0.75, 0.75, 0, 0.5, -0.75, 0.75,
-0.5, 0
]);
const pLineBuf = gl.createBuffer();
const cLineBuf = gl.createBuffer();
const pPolygonBuf = gl.createBuffer();
スロットの共用
さらに今回は、vertexAttributeArrayへのデータ供給とアトポンによる用途指定を「描画時に」やっています。これはp5なんかも普通にやっているんですが、異なるジオメトリーでvertexAttributeArrayの同じスロットを共用しないといけないので、その都度中身を差し替えないといけないんですね。それで描画時にやる必要があるんです。まあ仕方ないですね。譲り合い、ありがとうです。
線の描画
まず線の描画からです。こんな感じ:
// データの供給
gl.bindBuffer(gl.ARRAY_BUFFER, pLineBuf);
gl.bufferData(gl.ARRAY_BUFFER, pLine, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, cLineBuf);
gl.bufferData(gl.ARRAY_BUFFER, cLine, gl.STATIC_DRAW);
gl.vertexAttribPointer(1, 3, gl.UNSIGNED_BYTE, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
uniformX(gl, pg, "1i", "uUseUniform", false);
gl.drawArrays(gl.LINE_LOOP, 0, 4);
ARRAY_BUFFER枠は普通に上書きされるので順繰りにバインドと当てはめ、用途指定をやっています。バインド解除。0と1を使うので0と1を有効化。いいですね。uniformを使わないので、シェーダー内で分岐処理するためにここはfalseにします。uniformを使うかどうかの指定をuniformでやっています。それでLINE_LOOPで外周の正方形ができました。いいですね。
八角形の描画
次に、ポリゴンの方はuniformで描画したいので、データの登録は0番スロットにしかしません。それと、uniformにより使う色を0~1で指定します。
// データ供給
gl.bindBuffer(gl.ARRAY_BUFFER, pPolygonBuf);
gl.bufferData(gl.ARRAY_BUFFER, pPolygon, gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 無駄に思えるがp5とか普通にこういうことしてたんだよ
gl.enableVertexAttribArray(0);
if(config.disable){
gl.disableVertexAttribArray(1);
}
uniformX(gl, pg, "1i", "uUseUniform", true);
uniformX(gl, pg, "3f", "uColor", 0.5, 1, 0.5);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 10);
とりあえずdisableは無視で。デフォルトではこの処理をやっていません。それで、0しか使わないので0だけ有効化します。ほんとは重複処理になるので不要です。0だけを使うことを明示するためにこう書いてます。というか、p5とかも普通にこうしています、というのも、ジオメトリに関連するスロットだけ有効化という形式にした方が汎用性が高いからです。それで、そのまま諸々準備して描画すると、冒頭のサムネイルになります。

そして、同じコードをFirefox系で走らせると、次のようになります:

無事消えてくれました。実は先ほどの画像はChrome系です。原因は先ほどのコードと完全に同じです。なぜこのようなことが起きるのか、鋭い人は多分普通に分かるので考えてみてくださいね。まあこの後すぐ説明するんですが。
vertexAttributeの戸締まり
映画のタイトルではないです。
まず、線の描画に関してはいずれの場合も問題ないですね。それは間違いないです。0番と1番を使ってるしデータの供給も完璧です。なので綺麗に枠が表示されます。しかしその中身は空っぽです。理由はスロットを0番しか使ってないことです。それでどうしてエラーになるかというと、開いてるスロットはすべて描画に使われるからです。八角形はTRIANGLE_FANで書かれています。頂点は10個なので、0~9のすべての整数に対して、開いてるスロットはデータを供給し、シェーダーが描画の準備をします。位置データに関しては問題ないんですが、色データに関してもそれが起きます...が、今回uniform彩色を採用しているので色データはありません。しかしスロットは開いています。このとき使われるデータは、線の描画に使われたデータです。入りっぱなし、開けっ放しですから当然ですね。戸締まりを怠ったせいです。それでデータを採取しようにも、12バイト分しかないので、すべて取るには10個の頂点に対して30バイト要るんですが、当然不足し、範囲外指定エラーになるんですね。
なおこのときFirefox系でデスクトップだと次のようなエラーが出ているはずです:
drawArraysInstanced: Vertex fetch requires 10, but attribs only supply 4.
先ほど説明したように、12バイトでは4つ分しか作れず、10個作れないのでエラーです。そういう意味ですね。
しかし先ほどと何が違うかと言えば、データが描画に寄与していないことです。uniform彩色なので関係ありません。それでもデータが入りっぱなし、スロット開けっ放しであればフェッチは実行されます。ちょっと理不尽な気もしますが、そういう仕組みなので仕方ないですね...。これを回避するための、いくつかの方法が考えられます。
意図せぬ範囲外指定を防ぐ
ご覧のように、複数のジオメトリで同じアトリビュートやプログラムを共有する場合、データの残存やスロットの開けっ放しにより予期せぬエラーが出ることが分かりました。ではそれを回避するにはどうすればいいでしょうか。いくつかあります。
ダミーデータで埋める
30バイト必要なんでしょ?どうでもいいデータを、どうせ使わないけれど、30バイト分用意すればいいじゃない。一番頭の悪いやり方ですね。まあそれで確かに問題は回避されるんですが、ありえないですね。頂点が今回10個だからいいですけど、3000だったら?30000だったら?いったい何バイト分のゴミが必要になるんでしょう。有効ではあるんですが、おそらく却下されるでしょう。
disableで開閉状況を管理
スマートなやり方ですね。使わないのであれば閉じればいいわけです。もっとも、これはグローバル状態をきちんと管理しないと難しいという難点があります。すべてのスロット、まあ16個でしょうが、それらに対して、開いたらフラグを立てて、使わないのに開いていたら閉じるわけです。ジオメトリとプログラムごとにどのスロットを使うかは千差万別なわけなので、適切な管理をするのは大変ですが、アトリビュート=3Dジオメトリという風に概念的に固定されていれば不可能ではないでしょう。Threeはもともとこうしていたようです。p5は、以前はやってなかったんですが、私がこのバグをきっかけに1.6.0以降で機能するように実装しました。今も続いています(~2.0.5)。
VAOで開閉状況を管理
まだやってませんが、vertexAttributeArrayの状態を(完全にではないですが)まるごと記録するVAOという装置があるんですよ。それを使って切り分けてしまえば、そもそも使わないスロットは開かないですから、何も問題は起きません。これがある意味決定版です。今回も、線と八角形でそれぞれVAOで処理を切り分ければ、ドローコール内でスロットの中身を差し替えるなどといった綱渡りをしなくても、そもそもデータも入らないしスロットも有効化しないということで、問題なくそれぞれを描画できます。詳しくはそのうち解説できるかと思いますが、とても便利な仕組みです。
disableを使う
このコードではdisableを使ってスロットを閉じています。チェックを入れるとdisableで1番が閉じるので、Firefox系でもきちんと描画され、エラーは出ません。冒頭のコードと違い、disableを実行した方がきちんとした見た目になるのは興味深いですね。まああんな不自然なフェッチは普通しないんですが、起きてる現象は一緒ですから、いわゆるテストコードです。テストコードは問題のあぶり出しによく使われます。それが作れるようになるのも技術です。
p5.jsの対応
先に述べた通り、p5は以前は描画のたびに必要なスロットを有効にする、まさにこの記事のようなコードを書いていました。それで問題なかったのは、常に全てのスロットにデータが供給されていたからです。使わなくても。たとえば、UVとかです。しかしp5.Geometryという機能があって、特定のアトリビュートを用意しないようにできるんですね。すなわち、UVとかです。ところがシェーダーは普通に開いていますから、データを要求してきます。
p5のデフォルトのシェーダーはあらゆる描画に対応するために、オールインワンですべての機能を併用させています。UVを使わないで描画する場合でも、一度でもUVを使う描画をしてしまうとその時点でスロットが開いてしまい。それ以降のタイミングでUVを使わない描画をしようとすればたちまち問題が発生する仕組みになっています。自分はその罠にまんまと引っかかって、傘の描画をしたんですが、スマホで見たら消えてしまっていました。その時の衝撃は凄かったですね。頑張って作った作品が、スマホで見られない。しんどかったですね。
参考までにissueはこちらです:
On my android phone sometimes objects in sketches drawn with webgl in p5.js disappear
コミットはこちら:
Disable unused vertexAttribute to avoid environment-dependent vanishing bugs #5970
それまでの理解では、描画のたびにジオメトリに相当するアトリビュートが有効化されて、そのジオメトリのアトリビュート「だけ」が描画に寄与すると思っていたので、まさか前の描画のアトリビュートが残ってるなんて思わないし、それのフェッチが原因であることにも、まあ、まず気付かない、ですね...。皮肉なことに、この経験を経てアトリビュートの理解がぐんと進みました。enableVertexAttribArrayは引数が整数しかありません。整数しかない。プログラムごとにアトリビュートのスロットがある、という発想からは理解できないことです。その辺が糸口となって、解決に至りました。
ほとんどの人は気づかないと思うんですが、自分はスマホで自分の作品を閲覧するのが好きだったので、頑張ってデバッグしました。いい思い出ですね。
まとめ
WebGLはアトリビュートで描画します。ステートマシン方式なので、アトリビュート配列という仕組みが採用されており、スロットは16個しかなく、多彩なジオメトリを描画するには差し替えが必須です。自分のコードでもVAOを駆使してとっかえひっかえしてます。きちんと管理して問題が生じないようにしましょう。
次回はvertexAttribで既定値を変更する方法についてサクッと紹介します。あと基礎編は様々なアトリビュートを紹介して、最後にエレメント描画をやって終わりです。
今回登場した関数
getError
- リンク:getError
- 概要:エラー情報を返す。基本的には直前のドローコールの内容に応じたエラーを返す。ただしコードのみで、詳細は取得できない。
- 構文:
const errCode = gl.getError();
- 引数:なし
- 返り値:エラーの内容に応じたコード
- gl.NO_ERROR: エラーなし
- gl.INVALID_ENUM: gl定数の入れ間違いなどで起きる
- gl.INVALID_VALUE: 数値指定で範囲外の場合などに起きる
- gl.INVALID_OPERATION: バッファの領域外指定など、未定義の値にフェッチした時などに起きる
- gl.INVALID_FRAMEBUFFER_OPERATION: framebufferの設定ミスなどで起きる
- gl.OUT_OF_MEMORY: メモリ不足などで起きるらしい(?)
- gl.CONTEXT_LOST_WEBGL: コンテキストが破棄されている場合に起きるらしい