はじめに
着せ替えメーカーが流行っているらしく、自分でも作ってみたくなったので、技術調査をしました。着せ替えメーカーとは、用意されたキャラクターのパーツ(顔・目・髪・口など)を組み合わせたり、位置を調整したりしてオリジナルのキャラクターを作ることのできるサービスです。
スマホでの利用も考えていたので、異なる画面サイズでも、同じ比率でパーツを描画したり、位置調整をするか、という点が技術課題でした。この点を調べていくうちに、Canvasであれば、描画領域をスケールさせることで、Canvas内部の座標系を維持したまま拡大・縮小できることが分かったので、今回はCanvasと、CanvasをReactで使いやすくするためのReact Konvaについて調査しました。
Canvasとは?
Canvasとは、Web上で2Dグラフィックスを描画するためのAPIです。一番簡単な例だと、このようにCanvasに描画することができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html lang="en"> <body> <canvas id="canvas" width="300" height="300"></canvas> <script> const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); ctx.fillStyle = "green"; ctx.fillRect(10, 10, 150, 100); </script> </body> </html> |
以下のように、緑色の図形が描画されます。
このように、CanvasのAPI自体はすぐに試すことができるのですが、命令型のAPIとなっているので、状態の変化に応じてCanvasの内容を操作するようなアプリケーションを操作する場合、複雑な処理になることが想像できると思います。
また、Reactを使ってアプリケーションを構築したいため、相性の良いライブラリを探してみたところ、react-konvaが便利そうだったので、試してみたいと思います。
React Konvaとは
まず、Konvaは、Canvasのラッパーライブラリです。Canvasの命令型APIをラップし、Konva独自の「レイヤー」オブジェクト上に、図形などを追加したり、イベントトリガーを追加していくことで、Canavsへの描画などを行なってくれます。詳しくはこちらのサンプルを見てください。
React Konvaは、KonvaをReactで使いやすくするライブラリで、React Componentを宣言するようにCanvasを描画することができます。
以下が簡単な例です。(React Konvaのサンプルコードです。)
Stageコンポーネントから始まり、直下にLayer、そしてその配下に描画したいオブジェクトを並べていくと、実際にCanvasに描画されます。また、通常のReactコンポーネントと同じような使い勝手なので、状態管理してCanvasに描画するには、通常のDOMをレンダリングする場合と同じように、mapを使って描画していきます。
それでは今回は、konva reactを使って簡単な着せ替えアプリを作ってみたいと思います。
着せ替えアプリっぽいサンプルを作成
サンプルを作成しました。完成形はこちらです。
こちらが全体のソースコードです
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | import { createRef, useEffect, useRef, useState } from "react"; import eye1 from "./assets/eye1.png"; import face1 from "./assets/face1.png"; import glasses1 from "./assets/glasses1.png"; import mouth1 from "./assets/mouth1.png"; import nose1 from "./assets/nose1.png"; import "./App.css"; import { Image, Layer, Rect, Stage } from "react-konva"; import useImage from "use-image"; import { v4 as uuidv4 } from "uuid"; import { useWindowSize } from "react-use"; import Konva from "konva"; // 選択可能なカテゴリ const Category = { Face: "face", Eye: "eye", Glasses: "glasses", Nose: "nose", Mouth: "mouth", } as const; type Category = typeof Category[keyof typeof Category]; // 着せ替えのパーツの型 type Part = { id: string; imagePath: string; category: Category; x: number; y: number; }; // 着せ替えパーツ定義 const PARTS = [ { id: uuidv4(), imagePath: face1, category: Category.Face, x: 60, y: 0, }, { id: uuidv4(), imagePath: eye1, category: Category.Eye, x: 92, y: 112, }, { id: uuidv4(), imagePath: mouth1, category: Category.Mouth, x: 104, y: 256, }, { id: uuidv4(), imagePath: nose1, category: Category.Nose, x: 168, y: 184, }, { id: uuidv4(), imagePath: glasses1, category: Category.Glasses, x: 40, y: 72, }, ] as Part[]; // Stage(Canvas)内のサイズ const BASE_SIZE = 410; const PartImage = ({ part }: { part: Part }) => { const [image] = useImage(part.imagePath); return <Image image={image} x={part.x} y={part.y} />; }; function App() { const [stageParts, setStageParts] = useState<Part[]>([ { ...PARTS[0] }, { ...PARTS[1] }, { ...PARTS[2] }, { ...PARTS[3] }, ]); const [selectedPartId, setSelectedPartId] = useState<string | null>(null); const { width } = useWindowSize(); const [stageSize, setStageSize] = useState(0); const ref = createRef<HTMLDivElement>(); const stageRef = useRef<Konva.Stage>(null); // パーツを移動ボタンで移動させる const translate = (x: number, y: number) => { const p = stageParts.find((p) => p.id === selectedPartId); if (!p) { return; } p.x += x * 4; p.y += y * 4; setStageParts(stageParts.map((pp) => (pp.id === selectedPartId ? p : pp))); }; useEffect(() => { setStageSize(ref.current?.clientWidth || 0); }, [width]); const scale = stageSize / BASE_SIZE; // 完成した着せ替えのダウンロード const download = () => { if (!stageRef.current) { return; } const link = document.createElement("a"); link.download = "stage.png"; link.href = stageRef.current.toDataURL(); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; return ( <div style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }} ref={ref} > {/*着せ替え表示*/} <Stage width={stageSize} height={stageSize} scaleX={scale} scaleY={scale} style={{ position: "sticky", top: "8px" }} ref={stageRef} > <Layer key="backgroud"> <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" /> {Object.values(Category).map((c) => { return stageParts .filter((p) => p.category === c) .map((p, i) => <PartImage part={p} key={i} />); })} </Layer> </Stage> {/*パーツの移動・削除ボタン、ダウンロードボタン*/} <div style={{ display: "flex" }}> <table border={1}> <tbody> <tr> <td></td> <td> <button onClick={() => translate(0, -1)}>↑</button> </td> <td></td> </tr> <tr> <td> <button onClick={() => translate(-1, 0)}>←</button> </td> <td></td> <td> <button onClick={() => translate(1, 0)}>→</button> </td> </tr> <tr> <td></td> <td> <button onClick={() => translate(0, 1)}>↓</button> </td> <td></td> </tr> </tbody> </table> <button onClick={() => setStageParts((prev) => prev.filter((p) => p.id !== selectedPartId)) } > 選択中のパーツを消す </button> <button onClick={download}>ダウンロード</button> </div> {/*パーツ選択エリア*/} <div> {Object.values(Category).map((c) => ( <div key={c}> <h2>{c}</h2> {PARTS.filter((p) => p.category === c).map((p, i) => ( <img key={i} style={{ height: "50px", background: p.id === selectedPartId ? "orange" : stageParts.some((pp) => pp.id === p.id) ? "pink" : undefined, }} src={p.imagePath} onClick={() => { if (stageParts.some((pp) => pp.category === p.category)) { setStageParts( stageParts.map((pp) => pp.category === p.category ? { ...p } : pp ) ); } else { setStageParts([...stageParts, { ...p }]); } setSelectedPartId(p.id); }} /> ))} </div> ))} </div> </div> ); } export default App; |
React Konvaの導入
yarnでインストールします。
1 | $ yarn add react-konva konva use-image |
react-konva
と
konva
が最低限必要なパッケージです。
use-image
はURLから画像ファイルを読み、React Konvaの
<Image />
コンポーネントに画像を読ませるためのhookです。
画像の描画
Canvasで画像を描画するには、React Konvaの
<Image image={img} />
コンポーネントを使用するのですが、propsに渡す画像は、
CanvasImageSource
という型になっています。
1 | type CanvasImageSource = HTMLOrSVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap; |
これらの型が意味するところはMDNで解説されているので詳しくは省略しますが、Canvas自体が画像を描画するには
CanvasImageSource
型が必要となるため、React Konvaでもこの型で渡す必要があるのです。
今回はPNG画像を表示するのですが、その場合はimg要素に画像を読み込ませ、そのrefをImageコンポーネントに渡す必要があります。その実装の手間を省くため、konvajsが直々に公開している
use-image
hookです。
このhookはシンプルなソースコードなので一読して欲しいのですが、imgタグを生成し、そのrefを返しています。ただ、エラーハンドリングもやってくれているので、おそらくこのhookを使った方が、楽に実装できるのではないかなと思います。
今回は、
PartImage
というコンポーネントを作り、ここで
useImage
hookを使って画像を取得し、
<Image />
コンポーネントを返しています。
以下は、先ほどのコードで実際に使っている部分のコードの切り抜きです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | const PartImage = ({ part }: { part: Part }) => { const [image] = useImage(part.imagePath); return <Image image={image} x={part.x} y={part.y} />; }; function App() { // 省略 return ( <div> <Stage> <Layer key="backgroud"> <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" /> {Object.values(Category).map((c) => { return stageParts .filter((p) => p.category === c) .map((p, i) => <PartImage part={p} key={i} />); // PartImageで画像描画 })} </Layer> </Stage> </div> ); } |
stateによるCanvas描画
このサンプルでは
stageParts
というステートがCanvasに描画すべき値を持っているので、このstateの変化に応じてStageの内容を書き換えています。以下がその部分です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <Stage width={stageSize} height={stageSize} scaleX={scale} scaleY={scale} style={{ position: "sticky", top: "8px" }} > <Layer key="backgroud"> <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" /> {Object.values(Category).map((c) => { return stageParts .filter((p) => p.category === c) .map((p, i) => <PartImage part={p} key={i} />); })} </Layer> </Stage> |
stateをmapして、React Konvaで用意されたコンポーネントをを返していくだけで、Canvasに描画されていきます。通常のDOMと同じように、stateが変更されれば、Canvas内の描画も更新されるようになっているため、Reactに慣れているととても分かりやすいインターフェイスです。
レスポンスシブ対応
Stageのpropsには、widthとheightの他に、scaleX、scaleYも指定することができます。このスケールを設定することで、Canvas内を拡大・縮小することができます。例えば、次のようにスケールを0.5にすると、Canvas内は半分に縮小されて表示されるため、縦横ともに、実際の倍の1000ピクセル分の領域まで描画することができます。
1 2 3 4 | <Stage width={500} height={500} scaleX={0.5} scaleY={0.5}> {/*このRectははみ出すことなくピッタリ描画される*/} <Rect width={1000} height={1000} fill="red" /> </Stage> |
この仕組みを使って、レスポンシブ対応をしてみました。その部分に関連するコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | // Stage内は常に410 x 410とする const BASE_SIZE = 410; const PartImage = ({ part }: { part: Part }) => { const [image] = useImage(part.imagePath); return <Image image={image} x={part.x} y={part.y} />; }; function App() { const { width } = useWindowSize(); const [stageSize, setStageSize] = useState(0); const ref = createRef<HTMLDivElement>(); useEffect(() => { // ウインドウサイズが変更されたら、stageの親divのwidthと同じサイズにする setStageSize(ref.current?.clientWidth || 0); }, [width]); // Stageのscaleを求める const scale = stageSize / BASE_SIZE; return ( {/*親divは、640px以下で縮む*/} <div style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }} ref={ref} > <Stage width={stageSize} height={stageSize} scaleX={scale} scaleY={scale} > {/*省略*/} </Stage> </div> ); } export default App; |
ウインドウサイズの変更を契機に、親divのサイズを取得し
setStageSize()
でセットします。このサイズは直接Stageのwidthとheightに入ります。そして、scaleは
stageSize / BASE_SIZE
で求めます。これで、Stageのサイズが変化しても、Stage内はそのまま拡大・縮小されて表示できるようになりました。
画像のエクスポート
Stageの画像をエクスポートするには、Stageのrefを取得し、
toDataURL()
を呼び出します。これはCanvasの関数で、Canvasに描画されている内容を画像化したData URIを取得することができます。
デフォルトではPNG画像が取得できるため、後はダウンロードしたり、どこかへアップロードしたりすることができます。今回は、ローカルにダウンロードするように実装しました。こちらが該当部分を切り抜いたソースです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | function App() { const stageRef = useRef<Konva.Stage>(null); const download = () => { if (!stageRef.current) { return; } const link = document.createElement("a"); link.download = "stage.png"; link.href = stageRef.current.toDataURL(); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; return ( <div style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }} ref={ref} > <Stage ref={stageRef} > {/*省略*/} </Stage> {/*省略*/} <button onClick={download}>ダウンロード</button> </div> ); } export default App; |
さいごに
React Konvaは、Reactの状態管理の仕組みと親和性が高く、アプリケーションに組み込みやすいことが分かりました。今後はReact Konvaが内部的にどのような仕組みCanvasに描画しているか、またそのレンダリングコストについても調査したいと思います。