この記事の目次
CSSでcanvasを広げたら、描いた模様までぼやけてしまった。WebGL canvasには、ページ内で見せる大きさと、描画に使う画素数という二つのサイズがあります。表示枠だけを広げても、描画する画素は増えません。
HTMLとJavaScriptの関数が読める人向けに、架空の展示パネルの縦帯を描きます。WebGL 1のclearとscissorを使い、シェーダーや3Dモデルは作りません。図柄を描く技術ではなく、サイズ変更後にも必要な描画領域をそろえる練習です。GPUが利用できない場合の文章も残します。
CSSの600pxと、描画用の600画素は同じとは限らない
CSSの幅はページ内の配置に使う値です。canvasのwidth・height属性は描画用の領域の寸法を指定します。CSSで600×200に見せていても、内部が300×150なら、縦横で違う倍率に引き伸ばされます。
devicePixelRatio、略してDPRは、CSSの1pxに対する物理画素の比を表す値です。MDNのdevicePixelRatioの説明で、表示環境やページのズームにより変わることを確認できます。DPRが2なら、600×200の枠に対して1200×400を計算候補にします。
ただし画素数は面積で増えます。縦横を2倍にすると4倍です。この例では負荷を際限なく増やさないためDPRの採用値を2までに制限します。これは教材上の方針で、どのGPUでも速く動く上限を実測した値ではありません。
表示枠を測ってから、描画とviewportをそろえる
処理の順番は、CSSの表示サイズを測る、採用するDPRを掛ける、整数に丸めて描画領域へ設定する、描き直す、です。サイズを変更した後は以前の絵が残ることを前提にせず、描画処理も呼び直します。
viewportは、正規化された座標を描画領域のどこへ対応させるかの指定です。viewportの資料に従い、描画バッファの実寸を使って更新します。今回のclearはviewportではなくscissorの範囲で切り分けますが、三角形などを描く処理へ進むときも、この更新を忘れない構成にします。
次の表では、CSSに枠線や内側余白を付けない条件を使います。サイズを測る対象にそれらが混ざると、描きたい面積との対応を別に考える必要があるためです。
| CSSの表示サイズ | 環境のDPR | 採用倍率 | 期待する属性値 |
|---|---|---|---|
| 600×200 | 1 | 1 | 600×200 |
| 600×200 | 2 | 2 | 1200×400 |
| 600×200 | 3 | 2 | 1200×400 |
| 300×200 | 2 | 2 | 600×400 |
縦帯と寸法を同じページに表示する
以下をpanel.htmlへ保存してブラウザーで開きます。追加ライブラリは不要です。canvasの図は説明用なので、何の模様かは外側の文章でも伝えます。描画が使えなければ、空の画像枠を見せ続けず文章へ戻します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>縦帯の解像度|練習</title>
<style>
body { margin: 16px; font-family: sans-serif; line-height: 1.8; }
main { max-width: 600px; margin: auto; }
canvas { display: block; width: 100%; height: 200px; }
canvas[hidden] { display: none; }
button { font: inherit; margin: 4px; }
:focus-visible { outline: 3px solid #ac4300; outline-offset: 3px; }
</style>
</head>
<body>
<main>
<h1>展示パネルの縦帯(架空の練習)</h1>
<p>図は濃紺と白の縦帯を交互に並べたものです。</p>
<canvas id="panel" role="img" aria-label="濃紺と白の縦帯">
濃紺と白の縦帯です。
</canvas>
<p id="size" role="status">描画を準備しています。</p>
<button id="redraw" type="button" disabled>寸法を再確認</button>
<button id="stop" type="button" disabled>描画を終了</button>
</main>
<script>
const canvas = document.querySelector('#panel');
const note = document.querySelector('#size');
const redraw = document.querySelector('#redraw');
const stop = document.querySelector('#stop');
const gl = canvas.getContext('webgl');
if (!gl) {
canvas.hidden = true;
note.textContent = 'この環境では描画できません。上の説明をご覧ください。';
} else {
const controller = new AbortController();
const options = { signal: controller.signal };
function draw() {
if (gl.isContextLost()) return;
const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
if (!cssWidth || !cssHeight) return;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(cssWidth * ratio));
const height = Math.max(1, Math.round(cssHeight * ratio));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
gl.viewport(0, 0, w, h);
gl.disable(gl.SCISSOR_TEST);
gl.clearColor(0.08, 0.15, 0.25, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.SCISSOR_TEST);
gl.clearColor(1, 1, 1, 1);
const band = Math.max(1, Math.round(8 * w / cssWidth));
for (let x = band; x < w; x += band * 2) {
gl.scissor(x, 0, Math.min(band, w - x), h);
gl.clear(gl.COLOR_BUFFER_BIT);
}
gl.disable(gl.SCISSOR_TEST);
note.textContent = `CSS ${cssWidth}×${cssHeight} / 描画 ${w}×${h}`;
}
const observer = new ResizeObserver(draw);
observer.observe(canvas);
window.addEventListener('resize', draw, options);
redraw.addEventListener('click', draw, options);
function finish(message) {
observer.disconnect();
controller.abort();
canvas.hidden = true;
redraw.disabled = stop.disabled = true;
note.textContent = message;
}
canvas.addEventListener('webglcontextlost', () => {
finish('描画を継続できません。上の説明をご覧ください。');
}, options);
stop.addEventListener('click', () => {
finish('描画を終了しました。再開するにはページを読み直します。');
gl.getExtension('WEBGL_lose_context')?.loseContext();
}, options);
redraw.disabled = stop.disabled = false;
draw();
}
</script>
</body>
</html>
scissorは、描画を変更できる長方形の範囲を指定します。scissorの説明のようにSCISSOR_TESTを有効にして、今回は白い帯を塗る範囲を限定します。これを最後に解除しないと、次の全体塗り直しまで狭い範囲に限定されるため、処理の前後で状態をそろえています。
最初だけ合う失敗と、CSSだけ広げる失敗
一つ目の失敗は、CSSのwidthだけ変更して属性を固定したままにすることです。表示枠が広くなっても描画画素は増えず、古い領域を拡大して見せます。修正ではdraw内で測ったサイズから属性を更新します。画面幅を変え、説明欄のCSS寸法と描画寸法の比が採用倍率に合うことを再確認します。
二つ目は、読み込み時に一度だけdrawを呼ぶことです。最初は合っていても、親の幅が変わると古い寸法が残ります。完成例はResizeObserverで要素のサイズ変更を監視します。ウィンドウを変えずにmainの最大幅を300pxへ直し、描画幅も更新されるかを試します。
DPRだけの変化が必ず要素のサイズ変更になるとは限りません。画面を別のディスプレイへ移した後などは「寸法を再確認」で現在値を使い直せます。移動時にも完全に自動追従させたい製品では、DPRの変更監視を追加する設計が必要です。
解像度の数値と、実機での画質評価を分ける
この例は整数への丸めを使った入門用の計算です。WebGLの実装上の注意にもある通り、非整数DPRや画素の境界では単純な掛け算だけで常に最良の画質になるとは限りません。写真の圧縮やテクスチャの補間を直す方法でもありません。
今回はシェーダー、テクスチャ、頂点バッファを作らないため、それらの削除処理はありません。「描画を終了」では監視とイベントを解除し、拡張が利用できればコンテキストも解放します。3Dの部品へ移す場合は、作成したGPU資源の後始末も必要です。常時のアニメーションはなく、動きを減らす設定でも同じ静止図を使えます。
数値、縮小、終了の三つを確かめる
15〜20分を目安に、幅600と300、DPR1と2で表の値を照合します。次に狭い画面でも横スクロールがなく、数値の末尾まで読めるかを見ます。最後に終了ボタンを押し、枠が消えて説明が残り、その後の幅変更では描き直されないことを確認してください。
WebGL無効の環境では、図の代わりに説明と描画できない案内が出るのが期待結果です。ブラウザー、OS、DPR、GPU環境と未確認条件を記録します。ヘッドレスのソフトウェア描画で寸法を確かめても、実機GPUの性能を測ったことにはなりません。画像の表示寸法の記事と比べ、表示枠を整える仕事と、内部の画素を用意する仕事の違いを説明できれば、この課題は完了です。カリキュラムでは次に学ぶ描画分野を確認できます。
TRY A LESSON
記事の内容を、課題で確かめる
記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。