ビューポートとDPR
ビューポートにふれる

今までは普通にキャンバスのサイズを400x400で取ってきました。普通なんですが、このまま突っ走ってしまうといずれちょっとまずいことになるんです。なぜかというと、スマホなどでは400x400のキャンバスを表示する際、引き伸ばして表示しているからです。ピクセルの解像度という概念があり、たとえば自分のAndroidでは2.625となっています。見かけ上は400pxくらいであっても実際は1000以上あるわけで、そこに400pxを置いた場合、内容によっては引き伸ばされてぼやけたりする場合があります。そのため一般には見かけ上のサイズは400pxにしつつ、実際のサイズを大きく取るわけです。その場合、ビューポートと呼ばれるWebGLの描画領域指定にも影響が出ます。そういう話です。そういう感じなので、描画の内容的にはやや薄いですが、お付き合いください。
どうでもいいですが、目玉焼きおいしそうですね(こら🍳)。
コード全文
devicePixelRatio
ディスプレイに何か表示する際、見かけと同じピクセルで表示されるとは限りません。実際には見かけよりもたくさんのピクセルが関わってる場合があります。自分のノートパソコンは1ですが、Androidのスマホの方は2.625です。1pxにつき2.625px分のピクセルが関わっています。これを合わせないと、内容によっては綺麗に表示されません。その濃度は、devicePixelRatioで取得することができます。
const DPR = window.devicePixelRatio;
それを考慮して、キャンバスのサイズを決めることにします。
const cvs = document.querySelector("#myCanvas");
cvs.width = 400*DPR;
cvs.height = 400*DPR;
cvs.style.width = `400px`;
cvs.style.height = `400px`;
これでスマホなどの場合、通常より大きめのサイズのキャンバスが用意されます。なお、描画自体は正規化デバイス座標を経由して行われるため、キャンバスの実際のサイズがどうなろうが、あまり影響はありません。しかしビューポートを設定する場合は実際の描画に影響が出るので、そこでちょっと考えることになります。
viewport
ビューポートとは描画領域です。WebGLの描画が実行される長方形の領域です。デフォルトでは、キャンバスの実際のサイズとなっています。y軸が上方向なので、左下が(0,0)で、右上が(横幅、縦幅)です。それを確かめるため、この記事ではそれをgetParameter()で取得しています。
// ビューポートの取得
const v = gl.getParameter(gl.VIEWPORT);
// Int32Array[0, 0, 400, 400]
// スマホだと確かめられないのでDOMを使う
//pElem.
const pElem = document.createElement("p");
pElem.style.position = `fixed`;
pElem.style.left = '5px';
pElem.style.top = '5px';
pElem.style.margin = '0px';
pElem.style.fontSize = `18px`;
pElem.innerText = `viewport: ${v[0]}, ${v[1]}, ${v[2]}, ${v[3]}`;
document.body.appendChild(pElem);
で、それだとスマホとかで見れないので、それを実行するためにdocument.bodyにpタグを取り付け、その中に情報として出力しています。私のノートパソコンだと、こんな感じで表示されています。

なお、私のスマホではこんな風です。

2行目は後で説明します。1行目はここで出力しているものです。このように、スマホではDPRが2.625(=21/8)であるため、サイズが1050x1050となるわけです。
それで、このまま描画してもいいんですが、左上に詰めてみようかと思いまして、ビューポートの変更を実行しています。それがここですね。
// 左上すみっこに描いてみようか
// viewportを意識しないと失敗する。
gl.viewport(0, 200*DPR, 200*DPR, 200*DPR);
const w = gl.getParameter(gl.VIEWPORT);
pElem.innerText += `\n new viewport: ${w[0]}, ${w[1]}, ${w[2]}, ${w[3]}`;
一応今、400x400で考えているわけです。それで、200x200に区切り、左上の200x200に描きたいので、左下を(0,200)とし、横に200,縦に200というわけですね。しかし単純に0,200,200,200としてしまうと解像度が高い場合に失敗します。なので、DPRを掛け算する必要があるわけです。それがこのviewport関数の仕事です。そのあとビューポートが変更になるので、確かめています。それがさっきの2行目ですね。ちゃんと解像度が反映されているのが分かると思います。
描画について
今回描画は重要じゃないんですが、まあ前回の復習も兼ねて円を描くことにしました。頂点の個数は202で、最初の一つが中心で、残りの201個は右側の始点を重複させたうえで外周200個を用意しています。こうするとTRIANGLE_FANでちょうと円になります。FANは点の個数-2個の三角形からなるので正しく200個ですね。色もlengthで雑に決めています。
注目すべきはどちらかというとgl.clear()の方です。
gl.useProgram(pg);
gl.clearColor(0.4, 0.2, 0.0, 1);
gl.clear(gl.COLOR_BUFFER_BIT); // clearはviewport無関係。
gl.drawArrays(gl.TRIANGLE_FAN, 0, 202);
gl.flush();
gl.clear()命令はキャンバス全体に影響します。ビューポートは関係ありません。ドローコールではないからです。そのためビューポートが変わっていてもちゃんと全体の色が単色で塗りつぶされます。もちろんポリゴン描画で正方形を描く場合、それはドローコールですから、当然ビューポートの影響を受けます。
話題はこれで終わりなんですが、次回につながるので、一応ビューポート座標についてもちょっとだけ触れておきます。
ビューポート座標
このあと入門編の最後に深度値に触れるんですが、その関連というわけではないですが、WebGLの描画がどこに行われるかについての基本をここで説明しようと思います。ビューポート座標と言います。
実行結果はこちらです。ただし、devicePixelRatioが1である私のノートパソコンの場合、です。

ビューポート座標の計算
これを説明します。次の図をご覧ください。

今までバーテックスシェーダで計算していた点の位置というのはデバイス座標と呼ばれるものでした。これは4次元のベクトルです。これを正規化したものが実は、正規化デバイス座標です。中心が(0,0)で左下が(-1,-1)で右上が(1,1)になるように正規化されたものです。実はそれはwで割ることで実現されます。今現在、これのz'には常に0が入っており、w'には常に1が入っているので、差がありません。
なお、wでの割り算の前に補間が実行されます。先に補間が実行され、次いで割り算です。実はここに矛盾があるんですが、今そこまでやると混乱するので、とりあえず聞き流してください...該当するピクセルに対応する点を調べていると考えて進めてください。
正規化デバイス座標に対し、0.5倍して0.5を足す処理が実行されます。これにより得られるものを正規化ビューポート座標といいます。このときの第三成分がいわゆる深度値(0~1, WebGLの場合のデフォルト)ですが、とりあえず流します。この記事で用があるのはXとYです。
最後に、ビューポートパラメータで変換すると、ビューポート座標が算出されます。お疲れ様でした。この値を実はシェーダー内で取得することができます。それがgl_FragCoordです。
void main(){
vec2 p = gl_FragCoord.xy;
// p.xは100~180
// p.yは200~320
float cx = (p.x-100.0)/180.0;
float cy = (p.y-200.0)/120.0;
fragColor = vec4(cx, cy, 1.0, 1.0);
}
そうです、この記事ではgl_FragCoordを用いて色を決めています。gl_FragCoordは図にあるように3次元のベクトルなので、xyスウィズルで2次元にしないとバグることがあります。注意してください。zには深度値が入っています。今、ビューポートは次のように設定されています。
// ビューポート設定
gl.viewport(100*DPR, 200*DPR, 180*DPR, 120*DPR);
左下が(100,200)で横幅180,縦幅120なので、それを考慮して0~1になるように決めています。それでこんな風になるんですね。ただこれはDPRが1の場合です。たとえばDPRが2の場合、こうなってしまいます:

まあuniformとか使えばいいんですけど、まだ習ってないんでね...あれはサクッと用意するにも割と準備が要るんで、後回しです。こうすれば同じ見た目になります:
void main(){
vec2 p = gl_FragCoord.xy;
// p.xは100~180
// p.yは200~320
float cx = (p.x-2.0*100.0)/(2.0*180.0);
float cy = (p.y-2.0*200.0)/(2.0*120.0);
fragColor = vec4(cx, cy, 1.0, 1.0);
}
なお、shadertoyに出てくるfragCoordというのはこれです。これを使って板ポリ芸をするのがshadertoyです。ただ、デバイスによって大きさが異なったりするので割と大変なんですよね...最近は触ってませんが。
先ほど説明した「矛盾」について触れます。ここは自分も詳しくないので、多分に憶測で話します。
まず基本的なことですが、フラグメントシェーダはピクセルシェーダというのがより正確な呼び名です。ピクセルごとに実行されるわけです。並列に。それゆえ高速ですが、その代わり単純な命令しか実行できないし、並列なので一部のピクセルが足を引っ張ると全体のパフォーマンスに影響するというデメリットもあります。それはおいといて、つまり最初に用意されるのは「ピクセル」です。ということはさっきの、補間っていったい...という話になってきます。
なので想像するとこうです。まず最初にピクセル、ビューポート上の座標が用意されます。次に、そこに該当するように、与えられたプリミティブの2つないし3つの頂点の寄与が計算される...はずです。そしてその寄与に基づいて先ほどの説明に従って補間が実行されたり、場合によっては描画がキャンセルされたりする...のではないかと思っています。多分ね。(点描画の場合はまた違うかもしれませんが...)
というわけで次回は深度値です。おたのしみに!
今回登場した関数
viewport
- リンク:viewport
- 概要:ビューポート(描画領域)を設定する
- 構文:
gl.viewport(x, y, width, height);
- 引数:
- x: キャンバスにおける描画領域の左端のx座標
- y: キャンバスにおける描画領域の下端のy座標
- width: xを左端とする描画領域の横幅
- height: yを下端とする描画領域の縦幅
- 返り値:なし
- 補足:gl.getParameter(gl.VIEWPORT);を使うと、現時点でのビューポート設定を(Int32Arrayの形で)取得できます。