Canvas, PixiJSからRetinaディスプレイに配慮した画像の切り出しを行う

PixiJS 管理下の Canvas を、Retina ディスプレイに配慮して画像として切り出す方法について調べた。

背景

  • 案件で「PixiJS で作った簡易フォトショアプリにて、PixiJS 管理下の Canvas の特定を範囲を画像化して切り出す」という要件があり、以下条件で対応した。
    • Retina ディスプレイに配慮し、基本、縦横 2 倍のサイズで画像化する
    • 生成した画像を背景画像として使用する場合があり、この場合は等倍のサイズで画像化し、できる限りぼやけを低減させる
  • できたけど PixiJS の機能を経由して実現したので、内部的に Canvas に対して何が行われてるのかがはっきりと理解できておらずモヤっとしていた...... ので調べてみた

Retina ディスプレイとは

まず基本から...(以下を見てざっとまとめ

ppi と dpi

  • ディスプレイは光を放つ点の集合でできており、この点を「ピクセル」という
  • ディスプレイの広さの単位には「インチ」が用いらる
  • インチは画面の対角線で長さで決まる
    • なので同じインチ数のディスプレイでも、アスペクト比(縦と横の長さの比率)が違えば、ディスプレイの面積は異なる
  • 1 インチ辺りのピクセルの密度は、ppi(pixel par inch)で表す(ピクセル解像度と言う)
  • 1 インチ辺りのドットの密度は、dpi(dots par inch)で表す

物理解像度と論理解像度

  • ドットはディスプレイ上の物理的な「点」であり、「点」ごとに光を放っている
  • iPhone4 以降、インチ数はそのままに、従来の 1 ドッドの面積を 2x2 ドッドで構成する高精細なディスプレイが登場した(2dpi)
  • 論理解像度では、この 2x2 ドッドを 1 ピクセルとして数えるため、iPhone4 は物理解像度は 640×960、論理解像度は 320×480 となる。
  • このような物理解像度が論理解像度を上回るディスプレイを、Retina ディスプレイと呼ぶ(物理解像度と論理解像度が一致する状態のことは「ドットバイドット」と言う)

画像の Retina ディスプレイ対応

  • Retina ディスプレイでは、画像の 1×1 ピクセルを 4 ドットに拡大して描画するので、論理解像度基準で画像を作ると輪郭がぼやけて表示されてしまう
  • そのため 2dpi の Retina ディスプレイでぼやけさせないようにするには、縦横 2 倍の画像を作り、CSS 等で表示サイズを 1/2 に調整する必要がある
  • 例えば Retina ディスプレイに対応した背景画像を 640x200px で作った場合は、次のような CSS を適用する
{
    background-size: 320px 100px;
}
  • ドットバイドットのディスプレイにとっては不要な処置とも言えるので、(手間を惜しまないのであれば)メディアクエリで次のように画像を出し分ける方法もある(CodeGrid で紹介されている)
@media screen and (min-resolution: 2dppx) { /* ... */ }
@media screen and (resolution >= 2dppx) { /* ... */ }
  • dppx という記述は Retina ディスプレイが出始めた当初はできなかったらしく、ググると以下のような書き方が良く見つかるけど今は非推奨
@media screen and (-webkit-min-device-pixel-ratio: 2) { /* ... */ }

Canvas.width, canvas.height によるサイズ指定(前置き

  • デモ
  • まず、基準となるサイズの指定は Canvas の width, height で設定し、CSS の width, height は、基準サイズ上で描かれた Canvas の絵や Canvas 自体の大きさの見た目を拡縮させる意図で使用する
  • CSS のみでサイズ設定しようとすると、次のように意図しない形状になってしまう
const size = 300;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;

ctx.fillStyle = "#0055aa";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#557799";
ctx.fillRect(20, 20, size - 40, size - 40);

  • 原因は、Canvas 要素がデフォルトで持つ縦横サイズに対し、CSS で引き伸ばした表示にしようとするため。CSS を当てる場合は先に Canvas 要素のサイズを設定する。
// 最初に要素のサイズを固定してから...
const canvasSize = 300;
canvas.width = canvasSize;
canvas.height = canvasSize;

ctx.fillStyle = "#0055aa";
ctx.fillRect(0, 0, canvasSize, canvasSize);
ctx.fillStyle = "#557799";
ctx.fillRect(20, 20, canvasSize - 40, canvasSize - 40);

// CSSで見た目のサイズを設定する
const cssSize = 150;
canvas.style.width = `${cssSize}px`;
canvas.style.height = `${cssSize}px`;

Canvas の Retina ディスプレイ対応

  • デモ
  • 特になんの対策をすることなく Canvas を描画し、Retina ディスプレイで見てみるとぼやけた表示になってしまう
ctx.fillStyle = "#557799";
ctx.fillRect(20, 20, canvasSize - 40, canvasSize - 40);

ctx.fillStyle = "#aaccff";
ctx.font = `${canvasSize - 40}px Arial`;
ctx.textBaseline = "top";

ctx.fillText("あ", 20, 40);

  • 一旦 Canvas を大きいサイズにしてから、CSS で縮小表示させることでぼやけを解消してみる、まずは Canvas サイズを大きくし...
// 最初に要素のサイズを固定してから...
const canvasSize = 200;
canvas.width = canvasSize;
canvas.height = canvasSize;

// 解像度に合わせた拡大率を指定する
const scale = window.devicePixelRatio;
ctx.scale(scale, scale);
  • 見切れてしまった...

  • setScale をした場合、Canvas のサイズは変らず Canvas の中身のみ拡大されるので見切れてしまう
  • setScale する前に Canvas のサイズも大きくしておく(順番重要
const scale = window.devicePixelRatio;

// 最初に要素のサイズを固定してから...
const canvasSize = 200;
canvas.width = canvasSize * scale;
canvas.height = canvasSize * scale;

// 拡大率を指定する
ctx.scale(scale, scale);
  • ぼやけて大きく表示された

  • CSS で本来表示したかった見た目のサイズに戻す
// scaleで拡縮した見た目になったcanvasをstyleで本来のcanvasサイズに戻す
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
  • ぼやけが解消される

PixiJS の Retina ディスプレイの対応

  • デモ
  • 特に何も処置しなければ、Canvas で描画した時と同様にぼやける

  • resolution を指定すると...
const app = new PIXI.Application({
    ...
    resolution: window.devicePixelRatio || 1,
});
  • Canvas の scale と width と height の設定を同時にしてくれて、でっかく表示される

  • さらに autoDensity を指定すると、CSS で元のサイズの見た目に戻してくれる
    • ググると「autoResize を指定...」というのをよく見かけるけどこれは V4 の時の話で、V5 になって autoDensity に変わった(TS 使ってると、PixiJS のバージョンアップ時にこれ系の仕様変更はコンパイルエラーになるので便利
const app = new PIXI.Application({
    ...
    resolution: window.devicePixelRatio || 1,
    autoDensity: true, // 旧バージョンでは autoResize
});

Canvas の特定範囲を画像としての切り出す

  • 案件では、特定範囲だけ画像として切り出したいという要件だった
  • PixiJS では提供されない機能のため Canvas を用いて自前で行う必要がある
  • 以下手順を踏む
    • 特定の範囲を元 Canvas から別の Canvas に転写する
    • 転写した Canvas を画像変換する
  • この時、元 Canvas の width や height はsetScaleでの拡大率が反映された値になっているので、切り出し位置やサイズもこの拡大率を考慮したものにする
const scale = window.devicePixelRatio || 1;

// 切り出しはscale()で拡大したサイズを基準に行う必要がある。
// 文字周辺の塗りつぶしエリアを切り出そうとした場合、
// このエリアはCanvasに対し20pxのマージン位置に描画したので、
// この位置に対しscale()の拡大率を掛けて切り出し範囲を求める。
const srcCanvasX = 20 * scale;
const srcCanvasY = 20 * scale;

// srcCanvasは切り出し元のCanvas
const srcCanvasWidth = srcCanvas.width - 40 * scale;
const srcCanvasHeight = srcCanvas.height - 40 * scale;

const distCanvasX = 0;
const distCanvasY = 0;
const distWidth = srcCanvasWidth;
const distHeight = srcCanvasHeight;

// distCanvasは転写先のCanvas
distCanvas.width = distWidth;
distCanvas.height = distHeight;
distCtx.clearRect(0, 0, distWidth, distHeight);

distCtx.drawImage(
  // 切り出し元のCanvasを指定
  srcCanvas,

  // 切り出す位置とサイズ
  srcCanvasX,
  srcCanvasY,
  srcCanvasWidth,
  srcCanvasHeight,

  // 転写先Canvasにおける転写物の位置とサイズ
  distCanvasX,
  distCanvasY,
  distWidth,
  distHeight
);
  • Retina ディスプレイの場合、拡大したサイズで Canvas に転写される

  • 画像変換して、Retina ディスプレイの場合は 1/2 に縮小し使用する
// distImageは生成するImg要素
distImage.src = distCanvas.toDataURL("image/png");
distImage.style.width = `${distWidth / scale}px`;
distImage.style.height = `${distHeight / scale}px`;

等倍サイズでの切り出し

案件では

対象画像を背景画像として使用する場合は、等倍のサイズでなるべく画像をぼやけさせずに画像化する

という要件だった(画像を利用する側で background-size が使用できない、画像サイズがかなりでかいので容量を抑えたい、という事情がある)

  • 前述転写処理における転写元の Canvas は、Retina ディスプレイの場合のみwindow.devicePixelRatio倍で setScale()で拡大していたけど、これを常にsetScale(2, 2)とする
  • この元 Canvas を常に 1/2 にして画像を生成することで、等倍の画像化でも画質のぼやけ改善を見込める
// 倍率は2固定
const scale = 2;

const srcCanvasX = 20 * scale;
const srcCanvasY = 20 * scale;
const srcCanvasWidth = srcCanvas.width - 40 * scale;
const srcCanvasHeight = srcCanvas.height - 40 * scale;

const distCanvasX = 0;
const distCanvasY = 0;

// 転写先Canvasは1/2にする
const distWidth = srcCanvasWidth / scale;
const distHeight = srcCanvasHeight / scale;

// distCanvasは転写先のCanvas
distCanvas.width = distWidth;
distCanvas.height = distHeight;
distCtx.clearRect(0, 0, distWidth, distHeight);

distCtx.drawImage(
  // srcCanvasは切り出し元のCanvas
  srcCanvas,

  // 切り出す位置とサイズ
  srcCanvasX,
  srcCanvasY,
  srcCanvasWidth,
  srcCanvasHeight,

  // 転写先Canvasにおける転写物の位置とサイズ
  distCanvasX,
  distCanvasY,
  distWidth,
  distHeight
);

// 画像化して、CSSでリサイズせずそのまま使う
distImage.src = distCanvas.toDataURL("image/png");
  • ぼやけがちょっとマシになった等倍サイズの画像が生成される