着せ替えメーカーが流行っているらしく、自分でも作ってみたくなったので、技術調査をしました。着せ替えメーカーとは、用意されたキャラクターのパーツ(顔・目・髪・口など)を組み合わせたり、位置を調整したりしてオリジナルのキャラクターを作ることのできるサービスです。
スマホでの利用も考えていたので、異なる画面サイズでも、同じ比率でパーツを描画したり、位置調整をするか、という点が技術課題でした。この点を調べていくうちに、Canvasであれば、描画領域をスケールさせることで、Canvas内部の座標系を維持したまま拡大・縮小できることが分かったので、今回はCanvasと、CanvasをReactで使いやすくするためのReact Konvaについて調査しました。
Canvasとは、Web上で2Dグラフィックスを描画するためのAPIです。一番簡単な例だと、このようにCanvasに描画することができます。
<!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が便利そうだったので、試してみたいと思います。
まず、Konvaは、Canvasのラッパーライブラリです。Canvasの命令型APIをラップし、Konva独自の「レイヤー」オブジェクト上に、図形などを追加したり、イベントトリガーを追加していくことで、Canavsへの描画などを行なってくれます。詳しくはこちらのサンプルを見てください。
React Konvaは、KonvaをReactで使いやすくするライブラリで、React Componentを宣言するようにCanvasを描画することができます。
以下が簡単な例です。(React Konvaのサンプルコードです。)
Stageコンポーネントから始まり、直下にLayer、そしてその配下に描画したいオブジェクトを並べていくと、実際にCanvasに描画されます。また、通常のReactコンポーネントと同じような使い勝手なので、状態管理してCanvasに描画するには、通常のDOMをレンダリングする場合と同じように、mapを使って描画していきます。
それでは今回は、konva reactを使って簡単な着せ替えアプリを作ってみたいと思います。
サンプルを作成しました。完成形はこちらです。
こちらが全体のソースコードです
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;
yarnでインストールします。
$ 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
という型になっています。
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 />
コンポーネントを返しています。
以下は、先ほどのコードで実際に使っている部分のコードの切り抜きです。
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> ); }
このサンプルでは stageParts
というステートがCanvasに描画すべき値を持っているので、このstateの変化に応じてStageの内容を書き換えています。以下がその部分です。
<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ピクセル分の領域まで描画することができます。
<Stage width={500} height={500} scaleX={0.5} scaleY={0.5}> {/*このRectははみ出すことなくピッタリ描画される*/} <Rect width={1000} height={1000} fill="red" /> </Stage>
この仕組みを使って、レスポンシブ対応をしてみました。その部分に関連するコードです。
// 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画像が取得できるため、後はダウンロードしたり、どこかへアップロードしたりすることができます。今回は、ローカルにダウンロードするように実装しました。こちらが該当部分を切り抜いたソースです。
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に描画しているか、またそのレンダリングコストについても調査したいと思います。