はじめに
皆さんこんにちは!
日本のWebGL界において、意識高い系WebGLおじさんとして知られるエマ・デュランダルさんですよ!(挨拶)
さて、皆さんWebGL楽しんでますか?
WebGLはネイティブのOpenGLと違い、glGetErrorとかglShaderInfoLogとかで明示的にエラー確認を行わなくても、エラーが発生した際はブラウザが親切にちゃんとコンソールにエラーメッセージを出してくれます。
非常に3Dプログラミングしやすい環境だと思います。
それでも、「どうしてもこのエラーが取れない!! 正しくWebGLのAPIを呼び出してるはずなのに、どうしてエラーが出るの!? 亡霊かよこのエラー!」っと叫びたくなる時もあるものです。
どうしてか。大抵の原因は、WebGL(及びそのベースとなったOpenGL ES)のAPIについての理解不足にあるんです。
おやおや、今日もWebGLで悩める患者さんが来たようですよ。どうやら、複数種類のメッシュ(とそれぞれに付随する各シェーダープログラム)の描画時に起きるエラーに悩まされているようですが…。
(以降のやり取りは、私が開発しているWebGLライブラリ「GLBoost」の公式Gitterチャットルームにて実際にあったメンバーとのやりとりをベースに、その方達の許可を得て(登場人物を匿名化して)再構成したものです。)
原因不明な「GL ERROR :GL_INVALID_OPERATION : glDrawElements: attempt to render with no buffer attached to enabled attribute 1」亡霊かおまいは……。
Aさん「エマ先生!」
エマ「今日はどうされました?」
Aさん「ワーニングがとれんのです! 描画の際に謎のGL ERROR :GL_INVALID_OPERATION : glDrawElements: attempt to render with no buffer attached to enabled attribute 1
というワーニングが出て(Chromeの場合のワーニング文)、それ以降正常に描画できません!」
エマ「ほうほう、これはよくみんながハマるワーニングなんですよね。私もたまにありますよ」
Aさん「最初はANGLE_instanced_array
(ジオメトリ・インスタンシング拡張)の使い方が間違っているのかな、と思っていたんですが。使わないようにもっとコードをシンプルにしてみたんです。するとどうやらANGLE_instanced_array
は関係ないようで……」
エマ「まぁ、まずはコードを見てみましょうか」
エマ「ふむ。ここをこうして……と(コードをいじって検証中)」
Bさん「こんばんは。」
エマ「おお、Bさんこんばんは。」
Aさん「こんばんはー。」
Bさん「ログみました。2番用意、のところで、こんな感じに2行追加したところワーニングが出なくなったんですけど。どうでしょう?」
vbo2 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo2);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
//
-0.5, +0.5, 0, 0, 0,
//
+0.5, +0.5, 0, 1, 0,
//
-0.5, -0.5, 0, 0, 1,
]), gl.STATIC_DRAW);
gl.vertexAttribPointer(attribute2["position"], 3, gl.FLOAT, false, 20, 0); // 追加
gl.vertexAttribPointer(attribute2["uv"], 2, gl.FLOAT, false, 20, 12); // 追加
Aさん「おお!? 本当だ! なぜに!?」
Bさん「正確な理由は僕にもわからないんですけど。VBOを作成した直後にはgl.vertexAttribPointer
の指定が必要になるんですかね?」
エマ「いや、違いますね。」
Bさん「そうなんですか?」
エマ「Aさん、原因がわかりましたよ」
Aさん「マジですか!」
エマ「まずは以下のコードで試してみてください」
// 1番描画
var draw1 = function() {
gl.useProgram(program1);
gl.uniformMatrix4fv(uniform1["mMatrix"], false, mMatrix1);
gl.uniformMatrix4fv(uniform1["vMatrix"], false, vMatrix);
gl.uniformMatrix4fv(uniform1["pMatrix"], false, pMatrix);
gl.disableVertexAttribArray(attribute2["uv"]); // 追加
gl.bindBuffer(gl.ARRAY_BUFFER, vbo1);
gl.vertexAttribPointer(attribute1["noitisop"], 2, gl.FLOAT, false, 8, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo1);
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_SHORT, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
};
// 1番描画ここまで
// 2番描画
var draw2 = function() {
gl.useProgram(program2);
gl.uniformMatrix4fv(uniform2["mMatrix"], false, mMatrix2);
gl.uniformMatrix4fv(uniform2["vMatrix"], false, vMatrix);
gl.uniformMatrix4fv(uniform2["pMatrix"], false, pMatrix);
gl.uniform1i(uniform2["texture"], 0);
gl.enableVertexAttribArray(attribute2["uv"]); // 追加
gl.bindBuffer(gl.ARRAY_BUFFER, vbo2);
gl.vertexAttribPointer(attribute2["position"], 3, gl.FLOAT, false, 20, 0);
gl.vertexAttribPointer(attribute2["uv"], 2, gl.FLOAT, false, 20, 12);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture2);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo2);
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_SHORT, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
};
// 2番描画ここまで
Aさん「おお、警告が出なくなりました。ん? gl.disableVertexAttribArray
? これは一体どういうことなんでしょう」
エマ「まず、シェーダーのattribute
変数は、GPU上のレジスタと対応付けられるんです」
Aさん「はい」
エマ「例えばコード中の
attribute2["position"] = gl.getAttribLocation(program2, "position”);
のgl.getAttribLocation
は、その"position"
attribute変数に割り当てられているレジスタのインデックス番号を問い合わせますが、結局、レジスタ番号はレジスタ番号なのです。」
Aさん「・・・? どういうことでしょう?」
エマ「つまり、gl.getAttribLocation
で取得できて、gl.vertexAttribPointer
で指定することになる整数値(レジスタのインデックス番号)は、完全に独立の存在で、このシェーダープログラム"program2"
に属しているわけではない、ということです。」
Aさん「ふむふむ」
エマ「で、Aさんのオリジナルコードでは、1番用意と2番用意のコード部分で、gl.enableVertexAttribArray
を使って、指定したレジスタ番号に対応する頂点アトリビュートを有効化しています。まぁ、大抵はgl.getAttribLocation
で番号としては0と1が取れていると思うので、gl.enableVertexAttribArray
によって、0番と1番の頂点アトリビュート(レジスタ)が有効化されたわけです」
Aさん「そこまではわかります」
エマ「にもかかわらず、1番描画では、
gl.vertexAttribPointer(attribute1["noitisop"], 2, gl.FLOAT, false, 8, 0);
によって、レジスタ番号0番(attribute1["noitisop"])はVBOをアタッチしたけれども、レジスタ番号1については、まだアタッチできていませんよね? つまり、有効な頂点アトリビュート(レジスタ番号)なんだけど、VBOが未アタッチのものがある状態です。その状態で描画を行うと例のワーニングが出るわけです。」
補足
有効にした頂点アトリビュート(レジスタ)には、頂点データを供給する必要があります。つまり、VBOをアタッチしなければなりません。ただ、そのアタッチのためのWebGL関数gl.vertexAttribPointer
には、レジスタ番号を指定する引数はありますが、VBOを指定する引数はありません。どないすりゃこの二つを関連付けられるんじゃ! とお思いでしょう。
実は、gl.vertexAttribPointer
を呼ぶ前に、gl.bindBuffer
の呼び出しによってバインド状態になっていたVBOこそが、gl.vertexAttribPointer
によって指定されたレジスタへアタッチされる、という仕組みになっているのです。
これは、非常にわかりづらいAPI仕様だと私は思っています。っていうか、知ってました? そもそも知らない人もいたかもしれません。こういうのがWebGL(OpenGL)の面倒臭いところです。さらに最近はこのgl.vertexAttribPointer
によるVBOのアタッチ設定を記録する「頂点配列オブジェクト(VAO)」という拡張も登場し、さらにややこしくなっています。
(VAOについては、以前私が書いたこの記事をご覧ください)
(まぁ、こういうOpenGL系のAPIのややこしい部分も、glNextと言われているVulkan APIでは解決されているようですよ。まぁ、Vulkanはローレベルすぎて別の意味でややこしいんですが)
閑話休題。。。
Aさん「それが原因だったのか! あ、でもそれだとdrawを呼び出すたびに警告が出るんじゃないでしょうか。現状だと最初の一回だけです」
エマ「いやいや、2番目の描画で"uv"(レジスタ1)にも(ようやく)VBOが割り当てられたでしょう?」
Aさん「あ、なるほど! ようやく疑問が氷解しました! あとエマさんの言っていた、 「レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在」 という意味もわかりました。私は gl.useProgram
でシェーダープログラムを切り替えれば、gl.enableVertexAttribArray
とかの設定もクリアされると思ってたんです。 だって、gl.enableVertexAttribArray
で指定する番号は、gl.getAttribLocation
で取得しますが、そのgl.getAttribLocation
の引数にはシェーダープログラムを指定しますもの!」
エマ「ええ、ところが……gl.enableVertexAttribArray
はgl.useProgram
とは何の関係もないんです。 gl.useProgram
でのシェーダープログラムの切り替えにかかわらず、gl.enableVertexAttribArray
によるレジスタの有効化、gl.disableVertexAttribArray
によるレジスタの無効化は、効果が持続するんですよ」
Aさん「だから、エマさんの修正コードでは、1番描画ではレジスタ1を無効化して、VBOがレジスタ1にアタッチされていなくても問題ないようにしたんですね。で、2番描画ではレジスタ1も必要になるから、また有効化した、と」
エマ「そういうことです。ここまで見てきた知識を動員すれば、Bさんの回避策でワーニングが収まった理由もわかりますね」
Bさん「描画前の段階で、有効化したレジスタ0と1に対して、漏れなくVBOを最初にアタッチしたからですね。未アタッチのレジスタがなくなって、GL ERROR :GL_INVALID_OPERATION : glDrawElements: attempt to render with no buffer attached to enabled attribute 1
が出なくなったんだ。」
エマ「そう」
Aさん「使うシェーダーに応じて、gl.enableVertexAttribArray
やgl.disableVertexAttribArray
で、VBOをアタッチする必要のあるレジスタ番号を有効化したり、逆にアタッチする必要のないレジスタ番号を無効化したり、というのを律儀に制御する方法(エマさんアプローチ)と
最初に使う予定のある最大数の頂点アトリビュート(レジスタ)番号を全て有効化しておいて、とりあえず最初に全レジスタに何らかのVBOをアタッチしておく方法(Bさんアプローチ)、
どっちが良いんでしょう?」
エマ「まぁ、一番お行儀のよいのは私のアプローチですが、gl.enableVertexAttribArray
やgl.disableVertexAttribArray
はそれなりにコストのかかるgl命令なので、スピードを求めるならBさんアプローチもありな気がします。ただ、これもね。WebGLの実装系によっては、シェーダーで実際に使っていないのに有効化されているレジスタ番号があるとワーニングが出るケースもあるかもしれませんし、あともしかしたら、不必要なレジスタを有効化しているだけで、GPUの実行パフォーマンスに悪影響が出る処理系も、中にはあるかもしれません。ここらへんは私も深いノウハウがあるわけではないので、断言はできないですね。もし、不都合が出たら、私のアプローチに戻ると良いかと思います。」
Aさん「了解です。先生!」
エマ「それにしても、こういうところがWebGL(OpenGL)系のわかりづらいところなんですよね。どのAPIがどのステートと関連づいているのか、あるいは、今回のレジスタ番号のように、どういったものが独立している設定なのか。ぱっと見わからないんです。」
Aさん「そうですね。gl.getAttribLocation
にしても、これシェーダープログラムを指定しますけど、結局のところこのシェーダープログラムのこのattribuet変数に割り当てられているレジスタ番号はいくつ?
っていう、レジスタ番号を問い合わせるためのただの便利機能みたいなものだったわけですしね。これ、今回のことを知らない人がこのgl.getAttribLocation
の関数仕様を見ると、シェーダープログラムの切り替えで頂点アトリビュート(レジスタ)の状態も切り替わるって思っちゃっても不思議はないですよ。実際は違うわけですが」
まとめ
という次第でした。まとめると、
- 描画したいメッシュが複数種類あり、それらの頂点レイアウトや頂点シェーダーのattribute変数の数が異なっている場合に起きやすいWebGLエラーが、今回の
GL ERROR :GL_INVALID_OPERATION : glDrawElements: attempt to render with no buffer attached to enabled attribute {レジスタ番号}
である。 - レジスタ番号の有効・無効状態は、どんなGLのステートからも独立した設定。gl.useProgramによるシェーダープログラムの切り替えの影響も受けない。
- 描画するメッシュの種類によって頂点シェーダーのattribute変数の数が異なる場合、メッシュ種類の処理順番などによっては、有効になっている頂点アトリビュート(レジスタ)番号に対して、
gl.vertexAttribPointer
でVBOをアタッチできていないケースがあり、その状態で描画をするとGL ERROR :GL_INVALID_OPERATION : glDrawElements: attempt to render with no buffer attached to enabled attribute {レジスタ番号}
が発生する。 - このワーニングが発生したら、現在有効中のアトリビュート(レジスタ番号)の数・種類を確認して、それらに対してVBOをアタッチできていないケースがないか、またその状態で描画をしているタイミングがないか、確認するのが一番のデバッグ方針。
という感じになります。
私の感触として、WebGL初心者〜中級者の人がハマるエラーの中で、1、2を争うものが、本記事のケースだと感じています。
これ、今回説明したWebGLの仕様を知らないと、デバッグの方針が定まらなくていつまでたっても不具合が取れない、ということにもなりかねません。
Aさんがハマったこの問題。きっと同じことで悩んで、げんなりしている人は少なからずいるだろうと思いまして、急遽、本記事をGitterのメンバーの皆さんから許可を取って作成・公開した次第です。
WebGL、元のOpenGLの仕様をひきづって、ちょっとややこしいところもあるんですけど、まぁ現状Webで手軽に3Dができる(FLASH除けば)唯一の環境です。これからもみんなで可愛がっていきましょう。
あと、最後私から二言。
WebGL(OpenGL)系のAPIが(その時のステート状態がAPIの呼び出し効果に影響することが多々あるので)分かりにくい、って声が多々あるわけですが、私はどうして多少詳しくなったのか、というと、やはり困った時はOpenGL ESの仕様書を読んだりしてるわけですよ。
— エマ・デュランダル (@emadurandal) 2016年5月27日
GL仕様書は英語で書かれてて分量が多くて大変かもしれませんが、最初から舐めるように全部読む必要はないので(それはそれで有意義だと思うけど)、ぜひ読むことに挑戦して欲しいですね。さすがに仕様書にはステート状態とAPIの関係とかもしっかり書いてあるので、読めば迷いがなくなります。
— エマ・デュランダル (@emadurandal) 2016年5月27日
あ、本記事に関して、突っ込みどころあったらどうぞマサカリ飛ばしてくださいw