コンテキストとキャンバス
第1章では「getContext('webgl2')が nullなら非対応」とだけ言って先へ進みました。この章ではコンテキストの取得をきちんと学び、あわせて WebGL 以前に canvas そのものにまつわる最大のハマりどころ —「なんだか、ぼやける」問題— を根絶します。three.js で言えばrenderer.setSize()と setPixelRatio()が黙ってやってくれていた仕事です。
この章で学ぶこと:
getContextの作法と、コンテキスト属性(第 2 引数)- canvas が持つ 2 つのサイズ — 描画バッファと CSS 表示サイズ
devicePixelRatioと「くっきり表示」の公式viewportとscissor— ピクセルへの対応付けと制限- ResizeObserver によるリサイズ追従の定石
1. コンテキスト取得の作法
canvas.getContext('webgl2')が返すWebGL2RenderingContext(通例 glと名付けます)が、WebGL のすべての API の入口です。取得にはいくつかルールがあります。
- 1 つの canvas に持てるコンテキストは 1 種類だけ— 同じ canvas に対して先に
'2d'を取得していると、'webgl2'はnullを返します(逆も同じ)。 - 2 回目以降の呼び出しは同じオブジェクトを返す— 取り直しても新しいコンテキストにはなりません。
- 非対応・失敗なら
null— ブラウザが古い場合のほか、GPU ドライバの問題で WebGL が無効化されている環境もあるため、分岐は常に書きます。 - ページ内で持てる数には上限がある— たとえば Chrome では 16 個で、超えると古いコンテキストから失われます(上限はブラウザにより 異なります。復帰の話は第35章)。本サイトのデモページが 1 ページあたりの canvas を絞っているのはこのためです。
2. コンテキスト属性 — 第 2 引数
getContextの第 2 引数で、描画バッファの性質を指定できます。指定できるのは最初の取得時だけで、後から変更はできません。
// 第 2 引数でコンテキストの性質を指定できる(省略時は既定値)
const gl = canvas.getContext('webgl2', {
alpha: false, // 常に不透明にする(既定は true)
antialias: true, // MSAA を「要求」する(既定どおり。保証はない)
preserveDrawingBuffer: false, // 表示後に中身を保持しない(既定どおり)
});
if (!gl) {
throw new Error('このブラウザは WebGL2 に対応していません');
}
// 実際に適用された値は、あとから確認できる
console.log(gl.getContextAttributes());| 属性 | 既定 | 意味 |
|---|---|---|
alpha | true | 描画バッファがアルファを持ち、後ろにあるもの(canvas の CSS 背景など)と合成される。第1章の「アルファ 0.5 で透ける」実験はこれ。falseにすると常に不透明 |
antialias | true | 図形の輪郭を滑らかにする MSAA を要求する。あくまでヒントで、環境によっては無効。効かない前提の対策は第33章 (FXAA) |
depth | true | 深度バッファを確保する。3D の前後関係の判定に必須(第13章) |
stencil | false | ステンシルバッファ(描画領域のマスク用)。本サイトでは当面使わない |
preserveDrawingBuffer | false | 表示(合成)後も描画バッファの中身を保持するか。既定では「描いたフレームは次には残らない」 前提で毎フレーム描き直す。軌跡を残す表現やスクリーンショットには別の手を使う(第29章・第36章) |
premultipliedAlpha | true | ページとの合成時、色をアルファ乗算済み(RGB ≤ A)として扱う。RGB がアルファを超えるピクセルの合成結果は仕様上未定義。半透明 canvas の色が想定とズレるときに疑う場所 |
powerPreference | 'default' | 'high-performance'/ 'low-power' で GPU 選択のヒントを出す |
表に載せたのは主要な属性です。このほかに、性能が極端に落ちる環境ではコンテキストの取得自体を 失敗させる failIfMajorPerformanceCaveat などもあります(第35章)。当面はすべて既定値のままで問題ありません。各属性は、必要になる章で改めて登場します。
3. canvas が持つ 2 つのサイズ
ここからがこの章の本題です。canvas には、互いに独立した 2 つのサイズがあります。
- 描画バッファのサイズ—
canvas.width/canvas.height(HTML 属性)。WebGL が実際に塗るピクセルの数。指定しなければ300 × 150です。 - CSS 表示サイズ— スタイルシートで決まる、ページ上の見た目の大きさ (
clientWidth/clientHeightで読める)。
ブラウザは描画バッファの内容を、CSS サイズへ画像として引き伸ばして(または縮めて)表示します。 2 つが釣り合っていないと、拡大されてぼやけたり、アスペクト比が違えば歪んだりします。 「CSS でwidth: 100%にしたら 300 × 150 のバッファが横に引き伸ばされてボケボケになった」 が典型的な事故です。
もう 1 つ、コードには gl.drawingBufferWidth /drawingBufferHeightという値も登場します。canvas.widthが「この大きさで確保してほしい」という要求値であるのに対し、こちらは実際に確保された描画バッファのサイズです。GPU の上限を超えた場合などには要求より小さいバッファが作られることがあり、そのときアスペクト比が 保たれる保証もありません。そのため、WebGL に渡す値(後述の viewport など)には実確保値のほうを使うのが安全です。デモの表示には両方を出してあります。
4. devicePixelRatio — CSS ピクセルは物理ピクセルではない
さらにもう 1 段あります。高 DPI ディスプレイ(いわゆる Retina)では、CSS 上の 1px が物理的な複数ピクセルに対応します。その倍率がwindow.devicePixelRatio(以下 dpr)で、Mac のディスプレイで 2、スマートフォンで 2〜3 が普通です。Windows の表示スケーリングでは 1.25 や 1.5 といった非整数にもなり、ブラウザのズームでも変わります。
つまり「CSS サイズ = 600 × 400、dpr = 2」の canvas をくっきり表示するには、描画バッファは1200 × 800必要です。CSS サイズと同じ 600 × 400 しか確保しないと、物理ピクセルの 1/4 しか情報がなく、確実にぼやけます。冒頭のデモのボタンは、まさにこの 2 つの状態を切り替えています。
5. viewport — -1〜+1 の座標をどこに貼るか
描画バッファのサイズを正しく決めたら、次は WebGL 側にその使い方を伝えます。次章で学ぶとおり、 WebGL の図形の頂点は横も縦も -1〜+1 の正規化された座標系(仕様では NDC: 正規化デバイス座標と呼ばれます)で表されます。gl.viewport(x, y, width, height)は、その -1〜+1 を描画バッファのどの矩形に対応させるかの設定です(これも状態機械のスイッチの 1 つです)。通常はバッファ全面を指定します。
覚えることは 2 つです。
- 描画バッファのサイズを変えても viewport は追従しません。
canvas.widthを書き換えたら、gl.viewportも自分で合わせ直します。忘れると「リサイズしたら 一部にしか描かれない/引き伸ばされる」が起きます。 - viewport や scissor の座標の原点は左下で、y は上向きです。頂点の座標系と同じ向きで、DOM(左上原点・y 下向き)とは逆です。
6. scissor — クリアと描画をはさみで切る
gl.scissor(x, y, width, height)で矩形を指定し、gl.enable(gl.SCISSOR_TEST)を有効にすると、それ以降のクリアと描画はその矩形の内側に制限されます。 冒頭のデモの市松模様は、シェーダーを 1 行も書かずに、これと clearの組み合わせだけで描いています。
// 以降のクリア(と描画)は scissor の矩形内に制限される
gl.enable(gl.SCISSOR_TEST);
for (let y = 0; y * cell < height; y++) {
for (let x = 0; x * cell < width; x++) {
// 注意: scissor / viewport の原点は「左下」。y は上向き(DOM とは逆)
gl.scissor(x * cell, y * cell, cell, cell);
if ((x + y) % 2 === 0) {
gl.clearColor(0.09, 0.11, 0.14, 1.0);
} else {
gl.clearColor(0.17, 0.2, 0.26, 1.0);
}
gl.clear(gl.COLOR_BUFFER_BIT);
}
}
// 制限を解除しておかないと、以降のクリアが最後の 1 マスにしか効かなくなる
gl.disable(gl.SCISSOR_TEST);「clearColorで色を設定 → clearで実行」を、scissor の矩形を動かしながらマスの数だけ繰り返しています。enable/disableも状態機械のスイッチであることに注意してください。切り忘れると、この後のクリアがずっと最後の 1 マスにしか効かない、という事故になります。
7. リサイズ — ResizeObserver で追従する
表示サイズは、ウィンドウのリサイズだけでなくレイアウトの変化でも変わります。そこでwindowの resize イベントではなく、要素自身のサイズ変化を監視できるResizeObserverを使うのが定石です。手順は毎回同じです: サイズを計算し、変わっていれば描画バッファを作り直し、viewport を合わせ、描き直す。
function resize(): void {
const dpr = Math.min(window.devicePixelRatio, 2);
const width = Math.floor(canvas.clientWidth * dpr);
const height = Math.floor(canvas.clientHeight * dpr);
// width / height への代入は描画バッファの作り直し(中身は消える)なので、変化時のみ行う
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// 描画バッファのサイズが変わっても viewport は自動では追従しない。毎回合わせ直す
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
draw();
}
// 要素の表示サイズの変化を監視する。要素が表示されていてサイズが 0 でなければ、
// observe() の直後にも 1 回発火する
const observer = new ResizeObserver(resize);
observer.observe(canvas);canvas.widthへの代入は描画バッファの作り直し = 中身の全消去なので、値が変わったときだけ 代入します。消えるのは値が変わったときで、WebGL では同じ値の代入は素通りします (赤でクリアした WebGL2 canvas に同じ値を代入しても、readPixelsで読むと赤のままでした)。ただし 2D コンテキストは同じ値でも消えます(HTML 仕様が、同じ値の再設定の扱いをコンテキストモードごとに分けているためです)。- 逆に
clientWidth / clientHeightはレイアウトの結果を読むプロパティですが、ResizeObserver のコールバックはブラウザがレイアウトを終えたあとに呼ばれるので、ここで読むぶんには計算済みの 値が返るだけです(毎フレーム読む場合の条件は第4章 6 節)。 - observe() の直後にも 1 回発火するため、初回描画もこの経路で行えます。ただし要素が非表示 (
display: none)やサイズ 0 のときは初回発火しないので、折りたたみの中のデモなどでは注意が必要です。 - このデモはリサイズのたびに描き直す方式です。毎フレーム描画するようになると(第4章)、ループの 先頭でサイズをチェックする形に自然に統合されます。なお、次章のデモは静止画を 1 度描くだけの最小構成なので、リサイズ追従はあえて入れていません(第4章で導入します)。
コード全文
デモの全文です。WebGL の初期化とデモの本体(setup関数)を分けています。ボタンと数値表示のための DOM 操作が入っている以外は、この章で見てきたコードそのままです。
// 第2章: コンテキストとキャンバス
// 描画バッファと表示サイズの関係、devicePixelRatio、scissor、リサイズ追従。
// シェーダーはまだ使わない。市松模様は scissor + clear だけで描いている。
function setup(
gl: WebGL2RenderingContext,
canvas: HTMLCanvasElement,
readout: HTMLParagraphElement,
toggle: HTMLButtonElement,
): void {
// false にすると、描画バッファを CSS サイズのまま(dpr = 1 相当で)確保する。
// 高 DPI のディスプレイでは、引き伸ばされてぼやける様子が観察できる
let useHighResolution = true;
// 市松模様を描く。1 マスは CSS 上で 16px 固定(解像度を切り替えても見た目の大きさを変えない)
function draw(): void {
const width = gl.drawingBufferWidth;
const height = gl.drawingBufferHeight;
const scale = width / canvas.clientWidth; // 描画バッファのピクセル / CSS ピクセル
const cell = Math.max(1, Math.round(16 * scale));
// 以降のクリア(と描画)は scissor の矩形内に制限される
gl.enable(gl.SCISSOR_TEST);
for (let y = 0; y * cell < height; y++) {
for (let x = 0; x * cell < width; x++) {
// 注意: scissor / viewport の原点は「左下」。y は上向き(DOM とは逆)
gl.scissor(x * cell, y * cell, cell, cell);
if ((x + y) % 2 === 0) {
gl.clearColor(0.09, 0.11, 0.14, 1.0);
} else {
gl.clearColor(0.17, 0.2, 0.26, 1.0);
}
gl.clear(gl.COLOR_BUFFER_BIT);
}
}
// 制限を解除しておかないと、以降のクリアが最後の 1 マスにしか効かなくなる
gl.disable(gl.SCISSOR_TEST);
}
function updateReadout(): void {
readout.textContent =
`CSS サイズ: ${canvas.clientWidth} × ${canvas.clientHeight} px / ` +
`描画バッファ: 要求 ${canvas.width} × ${canvas.height} px・` +
`実確保 ${gl.drawingBufferWidth} × ${gl.drawingBufferHeight} px / ` +
`devicePixelRatio: ${window.devicePixelRatio}`;
}
function resize(): void {
const dpr = useHighResolution ? Math.min(window.devicePixelRatio, 2) : 1;
const width = Math.floor(canvas.clientWidth * dpr);
const height = Math.floor(canvas.clientHeight * dpr);
// width / height への代入は描画バッファの作り直し(中身は消える)なので、変化時のみ行う
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// 描画バッファのサイズが変わっても viewport は自動では追従しない。毎回合わせ直す。
// (このデモは clear しか使わないため viewport は結果に影響しないが、習慣としてここで設定する)
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
draw();
updateReadout();
}
toggle.addEventListener('click', () => {
useHighResolution = !useHighResolution;
toggle.textContent = useHighResolution
? '低解像度に切り替える (dpr = 1 相当)'
: '高解像度に戻す (devicePixelRatio に合わせる)';
resize();
});
// 要素の表示サイズの変化を監視する。要素が表示されていてサイズが 0 でなければ
// observe() の直後にも 1 回発火するので、初回の描画もこの経路で行われる
const observer = new ResizeObserver(resize);
observer.observe(canvas);
// devicePixelRatio の変化(ブラウザのズーム、dpr の異なるディスプレイへの移動)は
// CSS サイズが変わらないため ResizeObserver では検知できない。
// 「現在の dpr に一致する」メディアクエリの変化を監視して拾う
function watchDevicePixelRatio(): void {
const media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
media.addEventListener(
'change',
() => {
resize();
watchDevicePixelRatio(); // 新しい dpr で監視を張り直す
},
{ once: true },
);
}
watchDevicePixelRatio();
}
// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------
const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
const toggle = document.querySelector<HTMLButtonElement>('#toggle-resolution');
if (!canvas || !readout || !toggle) {
throw new Error('デモに必要な要素が見つかりません');
}
const gl = canvas.getContext('webgl2');
if (!gl) {
throw new Error('このブラウザは WebGL2 に対応していません');
}
setup(gl, canvas, readout, toggle);three.js との対応
| three.js | この章の生 WebGL2 |
|---|---|
new WebGLRenderer({ canvas, antialias, ... }) | getContext('webgl2', { ... })のコンテキスト属性(注意点は下記) |
renderer.setPixelRatio(window.devicePixelRatio) | dpr を掛けて canvas.width / height を決める |
renderer.setSize(w, h) | 描画バッファの更新 + gl.viewport の再設定 |
renderer.setViewport/ setScissor / setScissorTest | gl.viewport/ gl.scissor / enable(SCISSOR_TEST) |
リサイズ時の onWindowResize の定番コード | ResizeObserver + この章の resize() |
1 行目には注意があります。antialiasやpreserveDrawingBufferはコンテキスト属性としてそのまま渡りますが、既定値は一致しません(たとえば antialias は three.js では既定オフ、生 WebGL では既定オン)。またalphaは例外で、執筆時点の three.js は常に alpha: true でコンテキストを取得し、このオプションはクリア色のアルファの既定値(0 か 1)の切り替えに使われます。
手元で動かして、壊してみる
この章のコードは src/lessons/02-context-and-canvas/ にあります。
- ボタンで低解像度に切り替えて、マスの境界を見比べる。dpr が 1 のディスプレイでは差が出ないので、ブラウザのズームを変えて
devicePixelRatio(表示中)がどう変わるかも見てみる(Safari はズームで dpr が変わらないため、この方法では観察できないことがあります) - マスの大きさ(
16)を 4 や 64 に変えてみる。小さくすると clear の回数が数万回に跳ね上がり、リサイズ中の重さとして体感できます(CPU 側がボトルネックになる入口の感覚。第35章) gl.enable(gl.SCISSOR_TEST)をコメントアウトする — すべてのclearが全面に効くようになり、最後に設定した 1 色だけが残ることを確かめる(状態機械の復習)Math.min(window.devicePixelRatio, 2)の上限 2 を外し、 描画バッファのピクセル数がどれだけ増えるかを表示で確認するgetContext('webgl2', { alpha: false })に変え、console.log(gl.getContextAttributes())を足して、実際に適用された属性を見てみる
まとめ
- コンテキストは canvas ごとに 1 種類・取り直し不可・数に上限。属性は最初の
getContextでしか指定できない - canvas には「描画バッファ」と「CSS 表示サイズ」という独立した 2 つのサイズがある
- くっきり表示の公式は
canvas.width = clientWidth × devicePixelRatio(負荷と相談して上限を設ける) viewportは「-1〜+1 の座標系(NDC)とピクセルの対応」。リサイズ時に自分で更新する。ただしclearに効くのは viewport ではなくscissor- リサイズは ResizeObserver で検知し、「バッファ更新 → viewport → 再描画」
これで canvas とコンテキストの土台は完成です。次章では、いよいよ三角形を描きます。