マガジン

Canvasがぼやける:高解像度ディスプレイへの対応

遊びをつくる

執筆にClaudeを使用しています。

小さな四角いタイルを使って作られた、鳥と草木のドット絵。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

ブラウザゲームを作ってRetinaディスプレイや高DPIモニターで確認すると、文字やスプライトがぼんやりとにじんで見えることがあります。コードに誤りはなく、画像ファイル自体も鮮明なのに、画面に表示されると明らかに解像度が落ちて見える——この現象はCanvasの「論理サイズ」と「物理ピクセル数」のズレが原因です。 高解像度ディスプレイは1つの論理ピクセルを複数の物理ピクセルで描画します。iPhoneやMacのRetinaなら2倍、最近のAndroidや4Kモニターでは3倍になることもあります。このときcanvas.width = 800と書くだけでは、800×物理ピクセルではなく800×論理ピクセルのバッファが作られ、それを2〜3倍に引き伸ばして表示するため画像がぼやけます。修正方法は一つの値を変えるだけでなく、「バッファサイズ」「CSSサイズ」「描画スケール」の3つをセットで調整することです。

この記事の目次
  1. なぜぼやけるのかを図で理解する
  2. 基本的な修正コード
  3. リサイズ対応:ウィンドウサイズが変わるゲームの場合
  4. `imageSmoothingEnabled`との使い分け
  5. WebGLを使っている場合の対応
  6. 次の行動
  7. 参考資料

なぜぼやけるのかを図で理解する

window.devicePixelRatio(以下DPR)は「1論理ピクセルあたりの物理ピクセル数」を返すプロパティです。DPRが2のディスプレイでは、論理800×600のCanvasを正しく表示するために1600×1200の物理ピクセルが必要になります。

デフォルト状態では次のようなことが起きています。

  • canvas.width / height → 800×600(論理サイズのままバッファを作成)
  • ブラウザのレンダリング → 1600×1200の物理領域に引き伸ばして表示
  • 結果 → 1ピクセルの線が2物理ピクセルにまたがり、アンチエイリアスでぼやける

CSSでwidth: 800pxと指定してもバッファ解像度は上がらないため、ぼやけは解消されません。引き伸ばしを防ぐには、バッファをDPR倍の解像度で作り、描画コンテキストをDPR倍にスケールし、CSSで論理サイズに戻す——この3ステップが必要です。

基本的な修正コード

最小限の修正は以下のようになります。

js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

const logicalWidth = 800;
const logicalHeight = 600;
const dpr = window.devicePixelRatio || 1;

// バッファを物理ピクセルサイズで確保
canvas.width  = logicalWidth  * dpr;
canvas.height = logicalHeight * dpr;

// CSSで論理サイズに戻す
canvas.style.width  = logicalWidth  + 'px';
canvas.style.height = logicalHeight + 'px';

// 描画座標をDPR倍にスケール
ctx.scale(dpr, dpr);

この3行セットを一度設定すれば、以降のctx.fillRect(100, 100, 50, 50)などの座標指定は論理ピクセルのまま書き続けられます。DPRを掛けた座標を毎回手動で計算する必要はありません。

window.devicePixelRatio || 1の|| 1は、古いブラウザや一部の環境でプロパティが未定義になるケースへの保険です。省いても多くの場面では動きますが、つけておくのが安全です。

リサイズ対応:ウィンドウサイズが変わるゲームの場合

固定サイズのゲームならば上記で完結しますが、ウィンドウ幅に合わせてCanvasを伸縮させるタイプの作りにしている場合は、リサイズのたびに再設定が必要です。

js
function resizeCanvas() {
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.clientWidth;   // CSSで決まっている論理幅を読む
  const h = canvas.clientHeight;

  canvas.width  = Math.round(w * dpr);
  canvas.height = Math.round(h * dpr);

  ctx.scale(dpr, dpr);
}

window.addEventListener('resize', resizeCanvas);
resizeCanvas(); // 初期化

注意点が2つあります。まずctx.scaleは呼ぶたびに累積されます。リサイズ後に再スケールするときはctx.setTransform(dpr, 0, 0, dpr, 0, 0)を使うか、ctx.scaleの前にctx.setTransform(1, 0, 0, 1, 0, 0)でリセットしてから掛け直してください。累積を放置すると座標が崩れてクリック判定や衝突判定がずれる原因になります。

もう一点、Math.roundを使って小数点以下を丸めています。DPRが1.25や1.5など整数でない場合にバッファが小数サイズになるのを防ぐためです。DPRが整数でない環境はWindowsの125%スケーリング設定などで発生します。

`imageSmoothingEnabled`との使い分け

Canvasがぼやける原因を調べると「ctx.imageSmoothingEnabled = falseを設定すればよい」という情報に出会うことがあります。これは間違いではありませんが、解決する問題が別です。

imageSmoothingEnabledはdrawImageで画像を拡大・縮小描画するときのアンチエイリアス(スムージング)を切る設定です。ドット絵ゲームでスプライトがぼやけて見える場合には有効で、DPR対応と組み合わせて使います。一方でDPR対応をせずにこの設定だけ入れても、バッファ解像度不足によるぼやけは直りません。

  • DPRによるバッファ引き伸ばし、対処:canvas.width×DPR + CSSで論理サイズ指定 + ctx.scale
  • drawImageの拡大スムージング、対処:ctx.imageSmoothingEnabled = false
  • フォント描画のぼやけ、対処:上記DPR対応で改善、フォントサイズ指定は論理値でOK

両方の原因が重なっているゲームも多いため、DPR対応を先に入れてから残ったぼやけを確認し、必要ならimageSmoothingEnabledも追加するという順序で進めると問題の切り分けが楽になります。

WebGLを使っている場合の対応

getContext('2d')ではなくgetContext('webgl')やgetContext('webgl2')を使っているゲームでも考え方は同じです。

js
const dpr = window.devicePixelRatio || 1;
canvas.width  = Math.round(logicalWidth  * dpr);
canvas.height = Math.round(logicalHeight * dpr);
canvas.style.width  = logicalWidth  + 'px';
canvas.style.height = logicalHeight + 'px';

// WebGLのビューポートもバッファサイズに合わせる
gl.viewport(0, 0, canvas.width, canvas.height);

WebGLではctx.scaleではなくgl.viewportでレンダリング領域を指定します。プロジェクション行列やビュー行列を自前で計算しているときは、論理サイズを基準に計算を続けてバッファサイズには触れないのがシンプルです。PixiJSやThree.jsなどのライブラリを使っている場合は、それぞれのrenderer.setPixelRatio(window.devicePixelRatio)相当のAPIを使うと内部でこの処理を代行してくれます。公式ドキュメントで最新のAPIを確認してください。

次の行動

手元のゲームでconsole.log(window.devicePixelRatio)を実行してDPRが1を超えていればぼやけの原因と断定できます。本文の3行セット(バッファ拡大・CSS論理サイズ指定・ctx.scale)をゲームの初期化処理に追加してみてください。修正後にドット絵がくっきり見えるようになったらアソバースに投稿して、実際のデバイスで動作確認するのが確実です。

参考資料

  • MDN Web Docs「HTMLCanvasElement」
  • MDN Web Docs「Window: devicePixelRatio property」
  • MDN Web Docs「CanvasRenderingContext2D: scale() method」