0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

p5.jsで生のwebglを使ってインデックスバッファで用意時と描画時で違う型を使ってみる

Last updated at Posted at 2025-01-21

はじめに

 前回の記事:
 p5.jsで生のwebglでindexBuffer(インデックスバッファ)を使って遊ぶ
の補足的な内容です。データを用意するときに型付の配列を使ったと思いますが、この時と、描画時にも型を指定すると思います。これらが異なっている場合どういう挙動になるかという話です。たとえばUint32Arrayでデータを用意して、UNSIGNED_BYTEでdrawElementsを実行するといったことをするとどうなるかということです。

 普通に考えれば、データを配列で用意して、その内容がすんなり順繰りで出てくるのが分かりやすいでしょうが、入力時に扱われるのはあくまで「バイト列」です。その方が仕様としての汎用性が高いからです。ゆえに、2バイトずつとか4バイトずつといった情報は含まれていません。バイトごとにくっきりはっきり分かれた形でバッファに格納されています。なので、その取り出し方をUNSIGNED_BYTEやらUNSIGNED_SHORTといった形で指定しないと、数として取り出せないのです。そういう話です。

 なお、インデックスバッファについての基本はもう説明してあるので、端折りが多いです。ご容赦ください。

コード全文

 playWithIndexBuffer_type

// indexBufferで遊ぼう。
// TYPEどうなってるの?

/*
  webglの裏側
  globalState = {
    bindingBuffer:{
      arrayBuffer:null,
      elementArrayBuffer:null, // ←今回はここにバッファを紐付けようと思います
      uniformBuffer:null,
      ...
    }
  }

  同じタイプにすればいいんですが、違うタイプでもできるよって話。
*/

function setup() {
  createCanvas(400, 400, WEBGL);

  const vs_drawF =
  `#version 300 es
  uniform vec2 uPos[9];
  void main(){
    vec2 p = uPos[gl_VertexID];
    p = (p - vec2(1.0, 1.0)) / 4.0;
    gl_Position = vec4(p, 0.0, 1.0);
  }
  `;
  const fs_drawF =
  `#version 300 es
  precision highp float;
  out vec4 fragColor;
  void main(){
    fragColor = vec4(1.0);
  }
  `;

  const gl = this._renderer.GL;

  const pg = createShaderProgram(gl, {vs:vs_drawF, fs:fs_drawF});

  // 頂点の位置をuniformで決める。いずれアトリビュートでもできるようになる。
  const positionArray = [];
  for(let y=0; y<3; y++){
    for(let x=0; x<3; x++){
      positionArray.push(x, y);
    }
  }

  // Uint8で普通に作る。Uint8で利用するので普通に作る。
  const index8Array = [
    0,1,3, 6,3,7, 7,5,8, 1,2,5
  ];

  // Uint16で作る。つまり0~65535. リトルエンディアンを意識する。利用時はUint8.
  const index16Array = [
    0+256*1, 3+256*6, 3+256*7, 7+256*5, 8+256*1, 2+256*5
  ];

  // Uint8で作るが、利用時はUint16である。リトルエンディアンを意識して、
  // 下位ビットから並べる。
  const otherArray = [
    0,0, 1,0, 3,0, 6,0, 3,0, 7,0,
    7,0, 5,0, 8,0, 1,0, 2,0, 5,0
  ];

  // Uint8で作る
  const buf8_use8 = createIndexBuffer(gl, "uint8", index8Array);

  // Uint16で作る
  const buf16_use8 = createIndexBuffer(gl, "uint16", index16Array);

  // Uint8で作る(Uint16で利用する)
  const buf8_use16 = createIndexBuffer(gl, "uint8", otherArray);
  
  gl.useProgram(pg);
  setUniformValue(gl, pg, "2fv", "uPos[0]", positionArray);

  draw_8to8(gl, buf8_use8);
  draw_16to8(gl, buf16_use8);
  draw_8to16(gl, buf8_use16);

  // データ量は変わんないので、普通にやった方が面倒がないと思う。

  // ドローコールのあそこはgl.UNSIGNED_INTも指定できるが、そのためには
  // 拡張機能が必要...だったのだが、webgl2では不要なので安心です。とはいえ、
  // もちろん「普通に」利用するにはUint32Arrayでバッファを作らないと駄目。
  // また有効になってはいるが型指定の難しさが解消されたわけではない。

  // あと、どっかのサイトがUint16Arrayの代わりにInt16Arrayを使っているが
  // (どことは言わないけど)
  // (https://wgld.org/d/webgl/w018.html え??)
  // Uint16: 0~65535, Int16: -32768~32767
  // で表現範囲が違うし、可能なんだが、意図した挙動にならない可能性が
  // なくは無いので、やめましょうね。素直にUint使ってください。

  gl.flush();
}

function createIndexBuffer(gl, type, source){
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  switch(type){
    case "uint8":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(source), gl.STATIC_DRAW); break;
    case "uint16":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(source), gl.STATIC_DRAW); break;
    case "uint32":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(source), gl.STATIC_DRAW); break;
  }
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
  return buf;
}

function draw_8to8(gl, buf){
  // パターン1:Uint8で作ってUint8で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // バイトで作ってバイトで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // 12個のデータを処理するのでcountは12を指定する。
  // 配列はUNSIGNED_BYTEで登録したので、
  // ドローコールもUNSIGNED_BYTEでやんないとおかしなことになる。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_BYTE, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

function draw_16to8(gl, buf){
  // パターン2:Uint16で作ってUint8で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // ショートで作ってバイトで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // Uint16Arrayで6個の数を作ったが、バイト単位で取得するので、
  // 2倍して頂点数は12である。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_BYTE, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

function draw_8to16(gl, buf){
  // パターン3:Uint8で作ってUint16で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // バイトで作ってショートで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // 長さ24だが、SHORTとして扱うので2で割って結局12である。
  // 何が言いたいかというと結局countは頂点数である。
  // だってcountだから。全部一緒。
  // なおオフセットはインデックスバッファの方のバイト単位で指定する。

  // 実行すると分かるが、リトルエンディアン方式で数に変換している。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_SHORT, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

function createShaderProgram(gl, params = {}){
  /* 省略 */
}

function getActiveUniforms(gl, pg){
  /* 省略 */
}

function setUniformValue(gl, pg, type, name){
  /* 省略 */
}

実行結果:3つともすべて一緒

uint8.png

描きたいもの

 上にあるような、4枚の三角形です。設計図はこちら:

fewfr3eeve3geg34eg.png

これに基づいて頂点配列を決めて、インデックスの配列を決めて、描画します。uniformで用意するところ:

  // 頂点の位置をuniformで決める。いずれアトリビュートでもできるようになる。
  const positionArray = [];
  for(let y=0; y<3; y++){
    for(let x=0; x<3; x++){
      positionArray.push(x, y);
    }
  }

書いていて思いましたが、ほんとに、この程度ならユニフォームとインデックスを使った描画で充分ですね...あれこれ意識しなくていいので、仕様を調べることに集中できます。ありがたいです。アトリビュート便利ですが、大変なので。
 これを前回の記事みたいに位置調整に使って、三角形を作るだけです。今回はuShiftとかは出てきません。位置決めだけです。

vs_drawF
#version 300 es
uniform vec2 uPos[9];
void main(){
  vec2 p = uPos[gl_VertexID];
  p = (p - vec2(1.0, 1.0)) / 4.0;
  gl_Position = vec4(p, 0.0, 1.0);
}
fs_drawF
#version 300 es
precision highp float;
out vec4 fragColor;
void main(){
  fragColor = vec4(1.0);
}

あ、名前変えるの忘れた。まあいいや。

配列を作る

 今回、インデックスバッファは3つ作ります。Uint8で作ってUint8で取り出す用、Uint16で作ってUint8で取り出す用、Uint8で作ってUint16で取り出す用の3つです。さっきも述べたように、取り出し方を指定しないといけないので、用意の仕方と合わせて3通り実験しようというわけです。

  // Uint8で普通に作る。Uint8で利用するので普通に作る。
  const index8Array = [
    0,1,3, 6,3,7, 7,5,8, 1,2,5
  ];

  // Uint16で作る。つまり0~65535. リトルエンディアンを意識する。利用時はUint8.
  const index16Array = [
    0+256*1, 3+256*6, 3+256*7, 7+256*5, 8+256*1, 2+256*5
  ];

  // Uint8で作るが、利用時はUint16である。リトルエンディアンを意識して、
  // 下位ビットから並べる。
  const otherArray = [
    0,0, 1,0, 3,0, 6,0, 3,0, 7,0,
    7,0, 5,0, 8,0, 1,0, 2,0, 5,0
  ];

一つ目は普通に数を並べているだけです。上の画像を見ればわかると思います。コピーしただけです。次は、数を2つずつ合わせてUint16の整数にして6つ並べています。方式はリトルエンディアンです。最後の配列は、Uint8で作るんですが、利用時にUint16で利用することを想定して、リトルエンディアンで並べています。

 エンディアンについてざっくり説明すると、バイト列で考えた時に低いバイトから並べるか高いバイトから並べるかということです。たとえばUint32ArrayやFloat32Arrayなどの型付配列はリトルエンディアンを採用しているので、それを意識しないと面倒なことになります。取得時も、webGLはリトルエンディアンを想定してるらしいです。

 こんなことしなければ問題ないんですが...挙動で遊ぶのが目的なので、そこはおいておきます。

インデックスバッファを作る

 配列ができたらインデックスバッファを作ります。

function createIndexBuffer(gl, type, source){
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  switch(type){
    case "uint8":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint8Array(source), gl.STATIC_DRAW); break;
    case "uint16":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(source), gl.STATIC_DRAW); break;
    case "uint32":
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(source), gl.STATIC_DRAW); break;
  }
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
  return buf;
}
  // Uint8で作る
  const buf8_use8 = createIndexBuffer(gl, "uint8", index8Array);

  // Uint16で作る
  const buf16_use8 = createIndexBuffer(gl, "uint16", index16Array);

  // Uint8で作る(Uint16で利用する)
  const buf8_use16 = createIndexBuffer(gl, "uint8", otherArray);

webGLBufferを生成し、一旦グローバルステートのelementArrayBuffer枠に紐付け、紐ついたものが使われるので、データのバッファリングを実行。型については指定できるようになっています。終わったら紐付けを外します。STATIC_DRAWについては固定ですが、まあインデックスバッファは基本いじらないので、いいですね。

 後からいじることもできます!今は説明できないです。その場合DYNAMIC_DRAWなどを指定することになります。

プログラムを走らせる

 先にプログラムを走らせて必要なユニフォームを用意しておきましょう。

  gl.useProgram(pg);
  setUniformValue(gl, pg, "2fv", "uPos[0]", positionArray);

 前にも述べましたが、プログラムを走らせるのはユニフォームを登録する為でもあります。描画だけがプログラムを走らせる目的ではないことは、知っておくといい場合があります。

Uint8で作ってUint8で利用する場合

 最初に、オーソドックス、バイトで作ってバイトで利用する場合です。

function draw_8to8(gl, buf){
  // パターン1:Uint8で作ってUint8で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // バイトで作ってバイトで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // 12個のデータを処理するのでcountは12を指定する。
  // 配列はUNSIGNED_BYTEで登録したので、
  // ドローコールもUNSIGNED_BYTEでやんないとおかしなことになる。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_BYTE, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

 drawElementsではUNSIGNED_BYTEを指定します。1バイトずつ読み込んでね、という命令です。Uint8Arrayで作ったので、用意した数はそのまま放り込まれています。それを1個ずつ取り出しているだけです。問題ないですね。整数の個数は12個なので、12を指定します。

 もちろん、ここではやりませんがおそらくもっとも一般的なケースである「Uint16で作ってUint16で利用する」場合は、作る際はUint16Arrayで作り、利用時はUNSIGNED_SHORTで利用します。2バイトずつ取って、整数とするわけですね。

Uint16で作ってUint8で利用する場合

 次に、Uint16で作ったものをUint8で利用する場合です。

function draw_16to8(gl, buf){
  // パターン2:Uint16で作ってUint8で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // ショートで作ってバイトで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // Uint16Arrayで6個の数を作ったが、バイト単位で取得するので、
  // 2倍して頂点数は12である。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_BYTE, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

バイトで利用するのでUNSIGNED_BYTEを指定しますが、配列はSHORT,というかUint16で用意されています。

  const index16Array = [
    0+256*1, 3+256*6, 3+256*7, 7+256*5, 8+256*1, 2+256*5
  ];

たとえば3+256*6を普通にビットで書くと6の方が先に来ると思いますが、それがビッグエンディアンです。実際はバイト単位の取得では3が先に来ます。それがリトルエンディアンです。なので、結果的に0,1,3,6,3,7,...と整数が並んでいきます。個数は同じく12です。というか個数は全部同じでないとおかしいですね。配列の長さが6なので、2倍になっています。

Uint8で作ってUint16で利用する場合

 最後に、Uint8で作ってUint16で利用する場合です。おそらく無いと思いますが...

function draw_8to16(gl, buf){
  // パターン3:Uint8で作ってUint16で利用
  gl.clearColor(0.2, 0.3, 0.4, 1);
  gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);

  // バイトで作ってショートで利用
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
  // 長さ24だが、SHORTとして扱うので2で割って結局12である。
  // 何が言いたいかというと結局countは頂点数である。
  // だってcountだから。全部一緒。
  // なおオフセットはインデックスバッファの方のバイト単位で指定する。

  // 実行すると分かるが、リトルエンディアン方式で数に変換している。
  gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_SHORT, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

指定にはUNSIGNED_SHORTを使っています。2バイトずつ整数にして取得します。用意した列は、

  const otherArray = [
    0,0, 1,0, 3,0, 6,0, 3,0, 7,0,
    7,0, 5,0, 8,0, 1,0, 2,0, 5,0
  ];

なので、下位ビットから並んでいると考えないとおかしなことになります。つまりリトルエンディアンで解釈してSHORTの整数を作っています。それが12個なので、結局12です。

 なお、右側の2枚の三角形だけを描画する場合は、

  gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 12);

とします。なぜなら元の配列で最初の6枚分のデータ量が12バイトだからです。フェッチしているのは元のバイトデータなので、当然ですね。

 webGLがリトルエンディアンを想定していることは、たとえばここに記述があります。
 WebGLRenderingContext.vertexAttribPointer()

 まず、DataView を用いた JSON データから動的に配列バッファーを作成します。true の用法に注意してください。WebGL は私達のデータがリトルエンディアンであることを予期しています。

 DataViewの使い方について説明するかどうかわかりませんが、ざっくり言うとバイト列の形式でデータを整理するための超便利ツールだと思ってください。それのデフォルトがビッグエンディアンのため、trueでリトルエンディアンであることを明示しないといけないわけです。これを使うと、小数でも、整数でも、バイト形式で簡単に順繰りに並べることができます。

おわりに

 データ量は結局変わんないので、バイトで用意したならバイト、ショートで用意したならショートを指定するべきでしょう。それが一番楽ちんです。正直この手の知識がどう役に立つのかさっぱり分かんないですが、仕様を調べるものは楽しいものです。間違ってもInt8ArrayやInt16Arrayでデータを用意しないようにしてください。

 また、確かにショートで用意した場合にショートで使うことを明示すればいいんですが、内部的にはショートで用意したデータは、ここで実験して確かめたように、リトルエンディアンに基づいてバイト列で格納されています。小数や符号付き整数だとしても、です。そして描画時にUNSIGNED_SHORTを指定することで、2バイトずつ再びリトルエンディアンで解釈して整数とし、用いています。この仕組みを理解しておくと、今後アトリビュートとかいろいろ扱う際に役に立つと思います。すなわち、単なる配列ではなく、バイト列として扱っているということです。

 ここまでお読みいただいてありがとうございました。

補足:UNSIGNED_INT

 コメントアウトにも書きましたが、取り出すときの指定にはUNSIGNED_INTも指定できます。Uint32Arrayでデータを作った場合、これを指定することになると思います。表現できる数の範囲はなんと
 0~2^32-1 (4,294,967,295)
です。すげぇ。ていうか65536個より多くの頂点を扱いたい場合、それを超えるために用意した数の範囲が、結局これのビット数で言うところの2倍なので、まあ、2乗になるわけですね。webgl1ではこれを指定するのに拡張機能が必要だったんですが、webgl2ではそれをせずともUNSIGNED_INTが指定できるようになりました。とはいえ、きちんと指定しないと駄目なのは変わらないので、使いたい場合はきちんと指定しましょう。

 バイト単位でのデータの取り扱いに自分が詳しくないのは、自分が要するにエンジニアでなく素人だからです。仮数部とかの知識もあやふやです。なのでより一層新鮮に感じます。興味深いです。もっと知りたいと思います。

補足2:グローバルステートとプログラム

 グローバルステートといえば、プログラムについてもそうです。ちょっとモデルを書き換えた方がいいかもしれません:

  globalState = {
    currentProgram:null, // こんな感じ?
    bindingBuffer:{
      arrayBuffer:null,
      elementArrayBuffer:null,
      uniformBuffer:null,
      ... // いろいろ
    },
    // めっちゃいろいろ
  }

こんな感じでしょうか。で、useProgram()を用いると、あそこにプログラムオブジェクトがセットされます。これも一種のバインドですね。

  gl.useProgram(pg);
  // 以下の処理が実行される
  globalState.currentProgram = pg;

そしてもちろんnullをuseすればnullに戻ります。

  gl.useProgram(null);
  // 以下の処理が実行される
  globalState.currentProgram = null;

一度にバインドできるプログラムは一つまでです。そして、ドローコールの際に実行されるのはバインドされたプログラムです。もしここがnullの場合、実行されず、何も起きず、エラーが出ます。こんな感じでいろいろ隠蔽されているわけです。たとえばユニフォームをセットしたいだけの場合、対象となるプログラムを走らせて、ユニフォームをセットしたうえで、再びnullに戻しておく、といった使い方が想像できますね。仕組みを知るのは楽しいものです。

 もっとも、この辺は上げていくとキリがないのも事実です。たとえばたびたび登場しているclearColor()は色バッファをclear()でクリアするときの色を決めていますが、この色もそうですし、またclearDepth()で深度値をクリアするときの値を決められます(もちろんデフォルトは1)。さらにenable/disable(gl.DEPTH_TEST)で深度テストと書き込みを実行するか否かを決められます。いろんな状態が隠蔽されています...が、あれもこれをも意識するのは大変なので、一部を抜粋しているだけです。自分が重要だと思うのは主に次の3つです。

  • bindingBuffer(arrayBufferやelementArrayBufferのバインド状態)
  • vertexAttributeArray(アトリビュート描画に使う頂点アトリビュートの配列、そのうちやる)
  • textureSlot(テクスチャの入れ物、いわゆるテクスチャユニットを並べた配列、そのうちやるかも)

それ以外は、まあ実行したらその値になるよねくらいでいいと思います。p5.jsでもfill()やstroke()で内部状態がいじられますが、そういう感覚で問題ないかと思います。

(...ここにbindBufferBaseのあれを加えたいけど、理解が不十分なので自重)

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?