第1部 WebGL2 の基本 — 第2章

コンテキストとキャンバス

第1章では「getContext('webgl2')nullなら非対応」とだけ言って先へ進みました。この章ではコンテキストの取得をきちんと学び、あわせて WebGL 以前に canvas そのものにまつわる最大のハマりどころ —「なんだか、ぼやける」問題— を根絶します。three.js で言えばrenderer.setSize()setPixelRatio()が黙ってやってくれていた仕事です。

scissor と clear だけで描いた市松模様(シェーダーは次章から)。ウィンドウをリサイズすると ResizeObserver が検知して描画バッファを作り直します。ボタンで描画バッファを低解像度に切り替えると、高 DPI のディスプレイではマスの境界がぼやけます。下の数値で、いま実際に使われているサイズを確認できます。

この章で学ぶこと:

1. コンテキスト取得の作法

canvas.getContext('webgl2')が返すWebGL2RenderingContext(通例 glと名付けます)が、WebGL のすべての API の入口です。取得にはいくつかルールがあります。

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());
属性既定意味
alphatrue描画バッファがアルファを持ち、後ろにあるもの(canvas の CSS 背景など)と合成される。第1章の「アルファ 0.5 で透ける」実験はこれ。falseにすると常に不透明
antialiastrue図形の輪郭を滑らかにする MSAA を要求する。あくまでヒントで、環境によっては無効。効かない前提の対策は第33章 (FXAA)
depthtrue深度バッファを確保する。3D の前後関係の判定に必須(第13章)
stencilfalseステンシルバッファ(描画領域のマスク用)。本サイトでは当面使わない
preserveDrawingBufferfalse表示(合成)後も描画バッファの中身を保持するか。既定では「描いたフレームは次には残らない」 前提で毎フレーム描き直す。軌跡を残す表現やスクリーンショットには別の手を使う(第29章・第36章)
premultipliedAlphatrueページとの合成時、色をアルファ乗算済み(RGB ≤ A)として扱う。RGB がアルファを超えるピクセルの合成結果は仕様上未定義。半透明 canvas の色が想定とズレるときに疑う場所
powerPreference'default''high-performance'/ 'low-power' で GPU 選択のヒントを出す

表に載せたのは主要な属性です。このほかに、性能が極端に落ちる環境ではコンテキストの取得自体を 失敗させる failIfMajorPerformanceCaveat などもあります(第35章)。当面はすべて既定値のままで問題ありません。各属性は、必要になる章で改めて登場します。

3. canvas が持つ 2 つのサイズ

ここからがこの章の本題です。canvas には、互いに独立した 2 つのサイズがあります。

ブラウザは描画バッファの内容を、CSS サイズへ画像として引き伸ばして(または縮めて)表示します。 2 つが釣り合っていないと、拡大されてぼやけたり、アスペクト比が違えば歪んだりします。 「CSS でwidth: 100%にしたら 300 × 150 のバッファが横に引き伸ばされてボケボケになった」 が典型的な事故です。

描画バッファcanvas.width × heightWebGL が塗るピクセルの数引き伸ばし表示領域 (CSS)clientWidth × clientHeightページ上の見た目の大きさ画面に表示物理ピクセルCSS px × devicePixelRatio画面で実際に光る点の数canvas.width = clientWidth × devicePixelRatioこのとき描画バッファ 1 ピクセル = 物理 1 ピクセルで、くっきり表示になる
3 つの「サイズ」の関係。描画バッファが小さいと、引き伸ばしの時点で情報が足りず、ぼやけます。

もう 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 つです)。通常はバッファ全面を指定します。

-1〜+1 の座標 (NDC)(-1, -1)(+1, +1)viewport(x, y, w, h)描画バッファ全体wh(x, y)+x+y原点は左下(y は上向き。DOM と逆)
viewport は「-1〜+1 の座標系をどの矩形に貼るか」。全面以外を指定すれば、画面の一部にだけ描く こともできます(ミニマップなどに使えます)。

覚えることは 2 つです。

6. scissor — クリアと描画をはさみで切る

gl.scissor(x, y, width, height)で矩形を指定し、gl.enable(gl.SCISSOR_TEST)を有効にすると、それ以降のクリアと描画はその矩形の内側に制限されます。 冒頭のデモの市松模様は、シェーダーを 1 行も書かずに、これと clearの組み合わせだけで描いています。

src/lessons/02-context-and-canvas/main.ts(抜粋)
// 以降のクリア(と描画)は 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 を合わせ、描き直す。

src/lessons/02-context-and-canvas/main.ts(抜粋・簡略化)
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);

コード全文

デモの全文です。WebGL の初期化とデモの本体(setup関数)を分けています。ボタンと数値表示のための DOM 操作が入っている以外は、この章で見てきたコードそのままです。

src/lessons/02-context-and-canvas/main.ts
// 第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 / setScissorTestgl.viewport/ gl.scissor / enable(SCISSOR_TEST)
リサイズ時の onWindowResize の定番コードResizeObserver + この章の resize()

1 行目には注意があります。antialiaspreserveDrawingBufferはコンテキスト属性としてそのまま渡りますが、既定値は一致しません(たとえば antialias は three.js では既定オフ、生 WebGL では既定オン)。またalphaは例外で、執筆時点の three.js は常に alpha: true でコンテキストを取得し、このオプションはクリア色のアルファの既定値(0 か 1)の切り替えに使われます。

手元で動かして、壊してみる

この章のコードは src/lessons/02-context-and-canvas/ にあります。

まとめ

これで canvas とコンテキストの土台は完成です。次章では、いよいよ三角形を描きます。