はじめに
個人開発アドベントカレンダーとして何を書くか悩んだのですが、作ったもの列挙するのもありきたりかと思ったので、最近自分が考えていることについて書こうと思います。
それは、表題のとおり「実はSVGってCanvasの代わりに使えるんじゃないか?」ということです。
経緯
私は、個人開発で「サーモンラン研究所」(@salmon_lab)というアカウントを運営しています。これは、スプラトゥーン3というゲームのイベント情報を、リアルタイムにツイートするという非公式なbotです。
このbotはこんな感じで、イベントの詳細情報を画像でツイートすることができます。この画像機能の実装についてどうするか、ということが今回の出発点でした。
5年前の実装:PHP
こちらが5年前、2017年に作ったプログラムによる画像ツイートです。こちらは、スプラトゥーン3ではなくスプラトゥーン2のイベント情報をツイートしています。
この画像はPHPで生成しています。レンタルサーバーだったのでPHPしか使えませんでした。
PHPにはGDという組み込みライブラリがあり、これを使うと非常に低レベルの関数を使ってPNGを生成できます。
高度な機能は一切使えませんでしたが、画像合成+文字合成という要件は満たしており、PHP個人開発者にはおなじみの機能だったと思います。
Canvasを知る
JavaScriptでCanvasを使ったサービスを初めてリリースしたのは2年前、2020年だったような気がします。
このケツイがみなぎったジェネレーターは、UNDERTALEをモチーフにした画像ジェネレーターです。1週間でWebサービスを作るイベント「web1week」に合わせて開発しました。
当時技術記事(?)も書きましたが、やはりPHPとの違いについて強調して書かれていますね。当時の実装は、状態という考えがあまりなくDOMのイベントからなんとかしてCanvasを上書きしていくひどいものでしたが、それでもPHP GDよりは何倍も手軽に使えるものでした。
何よりローカルですぐに試せますからね。PHPもローカルで試すべきだろという話ですが。(PHPは本番環境で開発してました。デバッグしたい値をechoに書いて、画面上のどこかに出てきた値を探すんですよね~)
サーバーにおける描画
さて、Canvasはある程度Webに描画の自由をもたらしてくれるものでした。とはいえ、少なくともサーバーでは気軽に使えるものでもありませんでした。
まず第一に、フロントエンドでしか使えないという制約がありました。もちろんnode-canvasみたいなライブラリもあるんですが、コンパイルのために追加でインストールが必要で、ハマりどころが多そうな印象です。
第二に、サーバー上で手続き的に描画なんてしたくありませんでした。手続き的なプログラムが必要である、というのが、Canvasの大きな弱点だと感じていました。
例えば動的にOGP画像を生成する場合を考えてください。そのためにはサーバー側で画像を生成する必要があります。ですから、node-canvasを使う場合は、作りたい画像に合わせてCanvasの生成プログラムを毎回書き、結果を都度確認しながら仕上げていかないといけないのです。画像のデザインが変わるたびに毎回この作業が発生します。Canvas APIを操作するコードを吐いてくれるペイントソフトなんて無いので、手書きで。追加修正で間に新しくレイヤーを挟もうとして、バッファから作る羽目になったり。大量のif文が出来上がったり。
ぶっちゃけ苦しくないですか?欲しいのは画像を吐き出す関数であって、そのデザインはなんだろうと機能的には本来大きく変わる部分ではないはずです。毎回プログラムを書き換えるなんて、きっとReactのコードもびっくりしています。
もしも宣言的にクエリを書ければ、画像吐き出す部分なんてモジュール化してしまえるわけです。文字列だけで画像が定義できるなら、セキュリティの心配もないので、画像を返すAPIサーバとして機能を分離することだってできるはずです。
Canvasonの開発
上記のような課題を感じていたので、私は画像を定義するクエリを考えてみることにしました。
クエリといえば、「~~QL」。あの辺は大体JSONベースだった記憶があります。じゃあCanvasをJSONで定義できるようなモジュールを作ろう、という発想から開発を始めたのが、自作OSS「Canvason」でした。ちなみに未完成です。
コンセプトとしては、例えば以下のようにクエリを投げれば画像が返ってくるイメージでした。
import { generateImage } from "canvason";
const query = {
canvas: {
w: 1980,
h: 1080,
layers: [
{
format: "png",
src: "https://.../bg.png",
x: 0,
y: 0,
w: 1920,
h: 1080,
},
{
format: "png",
src: "https://.../stage_bg.png",
x: 100,
y: 100,
w: 800,
h: 450,
masks: [
{
format: "rect",
x: 100,
y: 100,
w: 800,
h: 450,
rounded: 40,
},
],
},
{
format: "otf",
src: "https://.../font.otf",
text: "Hello World",
color: "#000000",
size: 100,
x: 100,
y: 100,
},
],
},
};
const base64 = generateImage(query, { output: "base64" });
このときSVGで代替できるという発想はありませんでした。
だって、WebでSVGと言ったら画像ファイルの1つじゃないですか。なんかやたらCSSアニメーション職人が、画面をキラつかせるためにベクター画像として使っている印象しか無いわけです。画像フォーマットの1つなんだから当然読解は難しいと思っていましたし、ベクターじゃなくても画像全般を定義できるドキュメントである、という風に捉えたことがありませんでした。
sharpの限界
Canvasonの開発にあたっては、sharpというライブラリを使用しました。このライブラリはnode-canvasより軽量で、SVGをPNGに変換できるという話もあったので、きっと拡張性が高いだろうと思い採用しました。
基本的にはCanvasのように手続き的に画像を描画していくのですが、オーバーレイ合成といったフィルタ機能が充実したり、文字合成ができたりと、十分期待することができました。機能が多少少なかったとしても、SVGが使えるならきっとラップ関数を作れば対応できるだろうと見込んでいました。
しかし、これによって限界を知ることになります。
sharpは、SVGを完全に再現することができなかったのです。
このissueを見るとわかりますが、sharpはSVGのドロップシャドウフィルタが実装されていません。もちろんsharp本体にもシャドウ機能なんてありません。
sharpのつらみはそれだけではありません。もう一度先ほどのbotの画像を見ていただきたいのですが、4つ並んでいるアイテムの画像に黒と白の縁があるのがお分かりいただけるでしょうか?
これは元のPNG画像には存在せず、後から加工で付け足しています。この加工に必要な<feMorphology>
というSVGの機能は、こちらも(恐らく)sharpにはありません。
ちなみにこの<feMorphology>
という機能は、SVGの要素を太らせたり(dilate)痩せさせたり(erode)できます。
CSSからも使えるようなので、ぜひ試してみていただきたいです。かなり面白い表現になります。
sharpのつらみはさらにあり、それはテキスト描画です。sharpは描画周りをlibvipsに依存していますが、そこのバグなのか仕様なのか、テキストを入力すると、自動的に縦に伸びたり横に伸びたりします。テキストボックスに収まるように指定すると、縦横比を崩して収まろうとするので非常に厄介な挙動です。
少し前まで文字描画ができなかったらしいので、できるだけ良いとは思いますが…。
結論から言うと、凝った表現を目指しているこのプロジェクトには、軽量で簡素なプログラムを志向するsharpは合いませんでした。
SVG、実は素晴らしいのでは
最後にたどり着いたのがSVGでした。どういう経緯で決めたかは覚えていませんが、画像描画について調べるうちに、これはもうSVGから作ったほうが早いなという確信が強まっていました。
Canvasonは、JSONでクエリを定義するコンセプトはそのままに、SVGを出力するライブラリに変えようとケツイしました。
さて、Canvasonのほうは大幅なコード改変が迫られてしまったので、まずは生のSVGでbotの画像機能を作ってみることにしました。
実際のコードは以下のようなものです。
const { convert } = require("convert-svg-to-png");
import fs from "fs";
type GenerateImage = {
order: number;
stageName: string;
stageUrl: string;
weaponUrls: string[];
isBigRun: boolean;
};
export const toDataUri = (filePath: string, format: string) => {
const buffer = fs.readFileSync(filePath);
const base64 = buffer.toString("base64");
return `data:${format};base64,${base64}`;
};
const createSvg = ({
order,
stageName,
stageUrl,
weaponUrls,
isBigRun,
}: GenerateImage) => `
<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900"
width="1600" height="900">
<defs>
<filter id="white-border">
<feMorphology in="SourceAlpha" operator="dilate" radius="14" />
<feComponentTransfer>
<feFuncR type="linear" slope="-1" intercept="1" />
<feFuncG type="linear" slope="-1" intercept="1" />
<feFuncB type="linear" slope="-1" intercept="1" />
</feComponentTransfer>
<feDropShadow dx="8" dy="8" stdDeviation="10" />
</filter>
<filter id="black-border">
<feMorphology in="SourceAlpha" operator="dilate" radius="6" />
</filter>
<filter id="bright">
<feComponentTransfer>
<feFuncR type="linear" slope="1.5" />
<feFuncG type="linear" slope="1.5" />
<feFuncB type="linear" slope="1.5" />
</feComponentTransfer>
</filter>
<linearGradient id="stage-bg-gradient">
<stop offset="0%" stop-color="#000000" stop-opacity="0" />
<stop offset="20%" stop-color="#000000" stop-opacity="0.8" />
</linearGradient>
<rect id="stage-bg" x="0" y="0" width="100%" height="100%" fill="url(#stage-bg-gradient)" />
<filter id="stage" primitiveUnits="objectBoundingBox">
<feImage x="0%" y="-40%" width="140%" height="180%" preserveAspectRatio="none" xlink:href="#stage-bg" />
</filter>
<style>
@font-face {
font-family: "splatoon1";
src: url("${toDataUri(
"./assets/fonts/splatoon1jpja.ttf",
"font/ttf"
)}");
}
</style>
<symbol viewBox="0 0 1600 900" id="weapons">
<image x="10" y="340" width="395" height="395" href="${weaponUrls[0]}" />
<image x="405" y="340" width="395" height="395" href="${weaponUrls[1]}" />
<image x="800" y="340" width="395" height="395" href="${weaponUrls[2]}" />
<image x="1195" y="340" width="395" height="395" href="${
weaponUrls[3]
}" />
</symbol>
</defs>
<image x="0" y="0" width="1600" height="900" href="${stageUrl}"
style="${
isBigRun
? "filter: saturate(2) hue-rotate(80deg) contrast(140%) brightness(0.9)"
: ""
}" />
<image x="0" y="0" width="1600" height="900" href="${toDataUri(
"./assets/ui/frame.png",
"image/png"
)}" style="opacity: 0.7" />
<use href="#stage-name" filter="url(#stage)" transform="scale(1.4, 1.2) translate(-400, -26)" />
<image x="-160" y="-200" width="620" height="430" href="${toDataUri(
"./assets/ui/ink.png",
"image/png"
)}" preserveAspectRatio="none"
style="filter: drop-shadow(5px 5px 0 rgba(0,0,0,0.3)) saturate(0) brightness(0.08)" />
<text x="160" y="95" font-size="56" fill="#00ff59" font-family="splatoon1" text-anchor="middle">第${order}回</text>
<text x="1540" y="180" font-size="110" fill="white" font-family="splatoon1" text-anchor="end"
style="text-shadow: black 8px 8px" id="stage-name">${stageName}</text>
<use href="#weapons" filter="url(#white-border)" />
<use href="#weapons" filter="url(#black-border)" />
<use href="#weapons" filter="url(#bright)" />
<image x="0" y="0" width="1600" height="900" href="${toDataUri(
"./assets/ui/sousasen.png",
"image/png"
)}" style="mix-blend-mode:overlay;opacity: 0.6" />
</svg>
`;
export const generateImage = async (props: GenerateImage) => {
const png = (await convert(createSvg(props), {
puppeteer: { args: ["--no-sandbox"] },
})) as Buffer;
return png;
};
何をしているかサッパリだと思いますが、とにかくSVGからPNGを生成し、そのバッファを返却しています。
SVGの組み立てにあたっては、まずペイントソフトでデザインのリファレンスを作り、次にSVGファイルを直接作成してブラウザでリファレンスを再現していき、最後に変数を埋め込みました。
グラデーションや透過、オーバーレイ、移動変形、複製、ドロップシャドウ、dilateなど、かなり複雑な機能を使いましたが、SVGはその思いに応えてくれました。
SVGからPNGの変換には、convert-svg-to-pngを使いました。これは内部的にPuppeteerを使用しており、Docker環境で動かすのがかなり大変でしたが、色々やってなんとか動かすことができました。
完成して確信したことは、SVGはペイントソフトとほぼ同じことができる、ということでした。SVGはベクターを表現するだけではないのです。PNG画像の合成、文字の合成にも大活躍するのです。
終わりに
で、Canvasの代わりに使えるの?という話ですが、私は非常に高いポテンシャルで使えると思っています。少なくとも、Webの文脈では生Canvas程度でやっている作業ならすべてSVGで代替できると思います。ベクターを使えば解像度も上がり、一石二鳥です。
SVGはHTMLドキュメントに直接埋め込むことができますし、CSSと連動させたり、JavaScriptと連動させることもできます。CSSではちょっと手の届きにくい痒いところを、SVGは代わりに掻いてくれる存在です。助け合いです。アニメーションもお手の物。
私個人としては、今後全画面がSVGのWebアプリケーションを作ってみたいなと考えています。HTMLみたいなものなので、可能なはずです。UIを触るたびに例えばパスで構成されたタブがムニムニ動いたら面白そうじゃないですか?
2Dパズルゲームの開発にCanvasベースのゲームエンジンを作ろうと思っていましたが、こちらもフルSVGでいいのではないかと思っています。どの程度重くなるのかわかりませんが、いつか開発することがあったら、どんな感じだったかQiitaにてご報告しようと思います。
最後までお読みいただきありがとうございました。詳しい方がいらっしゃれば、ぜひコメントもいただけると嬉しいです。