
WEBブラウザ上でできる物理演算について色々と調べていたら、絵文字で物理演算を実現している方の記事を見つけた。この記事を読んでいるうちにおぼろげながら浮かんできたんです、ポケモンのアイコンでも同じことができて面白いんじゃないかと。
この記事でのやり方を参考にさせていただき、matter.jsとOpenCVを組み合わせてポケモンのアイコン(ゲーム画面で使用されているもの)で物理演算できるようにしてみた。
完成形はこちら
アイコン画像の取得
まず物理演算オブジェクトの生成元となるアイコン画像を取得する。
利用するのはPokeAPIというサイトで、ゲーム内のあらゆるデータを取得できるAPIが公開されている。
このサイトではトップページのフォームでAPIのレスポンスを確認することができる。
例えば、フォームにpokemon/407
と入力して「Submit」してみるとID No.407のロズレイドのデータのレスポンスが表示される。
今欲しいのはアイコン画像なので、レスポンス内を探していくとsprites/versions/
の中に歴代バージョン毎のアイコン画像のURLを見つけることができる。最新バージョンのものであればgeneration-viii
だ。
ついでに対象ポケモンの日本語名も後で使うので取得しておきたい。
先ほどのフォームにpokemon-species/407
と入力して取得されるレスポンスを確認すると、names
に言語毎の名前が配列で格納されているのことが分かる。この中のja-Hrkt
(ja
でもいい)表記のものだけ取って来ればいいが、必ずしも配列の0番目とは限らないので注意が必要。
なお、pokemon-species
のURLは、先のpokemon
レスポンス内のspecies/url
で取得できる。
アイコン画像のURLと日本語名の場所が分かったので、実際にAPIを実行するコードを書いて欲しいものを取得しよう。コードはPython (3.10.5)で書いた。
最新のポケモンは1000種類ほどいるらしいが流石に全部は多いので、とりあえず赤・緑〜ブラック・ホワイト(いわゆる初代〜第5世代)までの649種類に留めた。
import requests, json base = 'https://pokeapi.co/api/v2/pokemon/' end_number = 649 name_dict = dict(names=[]) for i in range(end_number): pokemon_url = base + str(i + 1) pokemon_res = requests.get(pokemon_url).json() # speciesデータから日本語名を取得する species_url = pokemon_res['species']['url'] species_res = requests.get(species_url).json() name = list(filter(lambda v: v['language']['name'] == 'ja-Hrkt', species_res['names']))[0]['name'] # json出力用辞書にIDと名前のセットを追加する name_dict['names'].append({ 'id': i + 1, 'name': name }) # アイコン画像ファイル情報を取得する icon_url = pokemon_res['sprites']['versions']['generation-viii']['icons']['front_default'] icon_res = requests.get(icon_url) # pngとして保存する path = './pic/' + str(i + 1) + '.png' with open(path, 'wb') as f: f.write(icon_res.content) if (i + 1) % 10 == 0: print('Complete to No.' + str(i + 1)) # IDと名前を一括でjson出力する with open('name_data.json', 'w') as f: f.write(json.dumps(name_dict, indent=2, ensure_ascii=False))
pokemon_res
やspecies_res
に先ほど中身を確認したレスポンスデータが入っているので、欲しい情報のところまでひたすら辿っていく。アイコン画像はそれぞれ[ID No].png
という名前で保存し、全ポケモンの日本語名は図鑑Noとセットでjsonファイルに書き出す。
{ "names": [ { "id": 1, "name": "フシギダネ" }, { "id": 2, "name": "フシギソウ" }, : 省略 : ] }
UI画面と処理の概要
ディレクトリ構成
. ├── docs │ ├── image │ ├── index.html │ └── main.js ├── node_modules ├── package-lock.json ├── package.json ├── src │ ├── index.js │ ├── json │ │ └── name_data.json │ ├── style.css │ ├── use_matter.js │ └── use_opencv.js └── webpack.config.js
環境
- jQuery 3.2.1
- npm 6.14.4
- webpack 5.74.0
- css-loader 6.7.1
- style-loader 3.3.1
- json-loader 0.5.7
- matter.js 0.18.0
- opencv.js 4.6.0
先ほど保存したアイコン画像は全てdocs/image
に入れjsonファイルはsrc/json
に入れて使う。
matter.jsの初期設定はuse_matter.js
、opencv.jsでの処理はuse_opencv.js
で行い、それらをindex.js
の中でモジュールとして読み込んで使っている。use_matter.js
、use_opencv.js
の処理を別ファイルに切り出したかったのでwebpackで管理している。
大雑把に言えば、プルダウンでポケモンが選択されたタイミングで対象のアイコンの輪郭抽出とテクスチャを生成しておき、物理演算用canvasがクリックされた時にそのアイコンオブジェクトを生成・投入するという流れになる。
この記事では、主にopencv.jsでの輪郭抽出とmatter.jsでのオブジェクト生成に関する部分について述べ、その他のUIに関する詳細な処理については省略する。
opencv.jsでアイコンの輪郭を抽出
opencv.jsの導入
OpenCVは様々な画像処理を行うことができるPythonのライブラリで、それをjavascriptとして使えるようにしたものがopencv.jsである。
導入方法は、CDNで読み込む方法とビルド済みのopencv.js
をディレクトリに配置して読み込む方法がある。
今回は簡単にCDNで読み込んでいる。
<script src="https://docs.opencv.org/4.6.0/opencv.js"></script>
輪郭抽出までの工程の概要
元のpng画像(背景は透過)からアイコンの輪郭を抽出するためには背景とアイコンの境界を明確にする必要があり、グレースケール化と二値化を行う必要がある。
各画素(ピクセル)の色合いを、黒を0、白を255とした多階調の画素値に変換する。つまり、カラー画像をグレーの濃淡で表現されるように変換する。
ある閾値を設定し、その閾値以下の画素値は0、閾値より大きい画素値は255にするといったように全ての画素の色を二分する。 グレーゾーンなどなく白か黒かはっきりさせるのだ。
輪郭抽出
対象となる画像要素imgElement
をMat
オブジェクト(画素値の行列、マトリックス)に変換し、それに対して各種の関数を適用して順次処理していく。各処理を行うための関数はcv
オブジェクトによって使うことができる。
各関数の引数には処理対象のMat
を指定するのはもちろんのこと、処理結果を保存する先のMat
も渡す必要がある。
この辺りはopencv.js特有の方法で本家のOpenCVとは異なるらしい。
なお、生成したMat
オブジェクトは自動でメモリ解放されないので、使い終わったらその都度自分でdelete
関数で削除する必要がある。
これらの画像処理をまとめているgetVerticesFromImageSrc()
は、抽出した輪郭の座標値vertices
を最終的に返しており、後でindex.js
で読み込んで使うことになる。
const getVerticesFromImageSrc = (imgElement) => { // img要素の画像を読み込む const src = cv.imread(imgElement); // グレースケール化用Matの生成 const dstGray = new cv.Mat(src.cols, src.rows, cv.CV_8UC1); // 二値化用Matの設定 const dstBinary = new cv.Mat(src.cols, src.rows, cv.CV_8UC4); // グレースケール化 cv.cvtColor(src, dstGray, cv.COLOR_RGBA2GRAY); // 二値化 cv.threshold(dstGray, dstBinary, 1, 255, cv.THRESH_BINARY); src.delete(); dstGray.delete(); // 輪郭の抽出 const contours = new cv.MatVector(); const hierarchy = new cv.Mat(); cv.findContours(dstBinary, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE); dstBinary.delete(); hierarchy.delete(); // 輪郭の座標を頂点として格納 const vertices = []; for (let i = 0; i < contours.size(); i++) { let d = contours.get(i).data32S; for (let j = 0; j < d.length; j += 2) { vertices.push({ x: d[j], y: d[j + 1] }); } } contours.delete(); return vertices; }; export { getVerticesFromImageSrc };
各関数の役割やどのような引数を設定しているか簡単に書いていく。
引数 | img要素(or画像パス) |
対象のimg
要素を読み込んでMat
オブジェクトに変換する。
引数 | canvas ID, 描画対象のMat |
指定のcanvasにMat
オブジェクトを書き出す。
輪郭抽出処理には直接必要なものではないが、確認用として頻繁に使っていたので一応記載。
引数 | 入力Mat, 出力Mat, 変換処理の種類, 出力画像のチャネル数 |
画像にさまざまな変換処理を行う。
「変換処理の種類」によってグレースケール化以外も行えるが、今回はcv.COLOR_RGBA2GRAY
を指定する。
「出力画像のチャネル数」は指定しなくてもデフォルト値0になる。
引数 | 入力Mat, 出力Mat, 閾値, 最大値, 閾値処理の種類 |
Mat
オブジェクトに対して閾値処理を行う。
「最大値」には画素値が閾値より大きい場合に置き換える値を指定する。
今回の「閾値」は1としているので、黒色の背景(透過pngなので画素値0)以外は全て白(最大値255)になる。
「閾値処理の種類」はTHRESH_BINARY
を指定する。
引数 | 入力Mat, 輪郭, 階層, 輪郭検索モード, 輪郭近似法, オフセット |
画像要素の輪郭を検索する。
出力結果1つ目の「輪郭」(MatVector
オブジェクト)に目的の輪郭の座標値がベクトルとして含まれる。
出力結果2つ目の「階層」(Mat
オブジェクト)に各輪郭線の親子関係などの情報が含まれる。今回は直接使用することはないが引数の指定は必要になる。
輪郭線は等高線のように多重の階層で検出されることもあるが、今回は一番外側の輪郭だけあればいいので「輪郭検索モード」をcv.RETR_EXTERNAL
にしている。
「輪郭近似法」は必要最小限の輪郭の点を検出するcv.CHAIN_APPROX_SIMPLE
にしている。
「オフセット」は全ての輪郭線を平行移動する量を設定するものだが今回は必要ないので設定していない。(デフォルト値はx, yともに0)
findContours
で得られた輪郭(コード中のcontours
)から座標値だけを取り出してx, y座標のオブジェクトの配列にする。
contours
にはアイコン画像の領域数分だけ輪郭線の情報(Mat
オブジェクト)が含まれている。大抵のポケモンのアイコンは1つの領域で描画されているが、中には複数に分かれているものもある。(No.109ドガースのアイコンなどが分かりやすい)
get
関数にインデックスを指定すれば各輪郭線の情報が取得できる。
それぞれの輪郭線のdata32S
(Mat
タイプCV_32S = 符号付き32ビット整数)に目的の座標値が入っているが、[x1, y1, x2, y2, …]
のように縦横の座標が1次元配列で格納されているのでx, y座標のオブジェクトを作るときは注意が必要。
matter.jsで物理演算
matter.jsの導入
matter.jsはWEBブラウザ上で2次元の物理演算ができるjavascriptのライブラリである。
物理演算の世界をhtmlのcanvas上に展開し、オブジェクトを投入したりマウス操作で動かしたり衝突させたり色々できる。
matter.jsの導入はnpm
でインストールする方法、ビルド済みのmatter.js
をディレクトリに配置して読み込む方法があるが、今回はnpm
を使った。
npm install matter-js
matter.jsの初期設定
matter.jsの初期設定関係の処理はbasicMatterConfig()
にまとめている。basicMatterConfig()
は、物理演算canvasを展開する先の要素canvas
とその幅width
と高さheight
を受け取るようにしている。
この関数内で物理演算の実行や描画、オブジェクトの生成に必要なモジュール群を読み込むが、その内Body
,Bodies
, Bounds
, Constraint
, Composite
とEngine
、MouseConstraint
インスタンスのengine
、mouseConstraint
はindex.js
でも使用するので返り値としている。
import Matter from 'matter-js'; const mousePointer = { x: 0, y: 0 }; const basicMatterConfig = (canvas, width, height) => { // 使用モジュール const Engine = Matter.Engine, Render = Matter.Render, Runner = Matter.Runner, Body = Matter.Body, Bodies = Matter.Bodies, Mouse = Matter.Mouse, MouseConstraint = Matter.MouseConstraint, Bounds = Matter.Bounds, Constraint = Matter.Constraint, Events = Matter.Events, Composite = Matter.Composite; // エンジンの生成 const engine = Engine.create(); // レンダリングの設定 const render = Render.create({ element: canvas, engine: engine, options: { width: width, height: height, wireframes: false, background: '#f0f6da' } }); // マウス、マウス制約を生成 const mouse = Mouse.create(canvas); const mouseConstraint = MouseConstraint.create(engine, { mouse: mouse, constraint: { angularStiffness: 0, render: { visible: false } } }); // マウスポインタの座標を格納する Events.on(mouseConstraint, 'mousemove', e => { mousePointer.x = e.mouse.position.x; mousePointer.y = e.mouse.position.y; }); Composite.add(engine.world, mouseConstraint); render.mouse = mouse; // レンダリングを実行 Render.run(render); // エンジンを実行 Runner.run(engine); return { Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint }; }; export { basicMatterConfig, mousePointer };
初めにEngine.create()
で物理演算エンジンのインスタンス生成する。このEngine
インスタンスは今後色々なところで必要になる。
他の設定が完了した上で Runner.run(engine)
とすれば物理演算が可能になる。
各種オプションを設定した上でRender.create()
でRender
インスタンスを生成する。
オプションのelement
で指定した要素に物理演算用canvasが挿入される。width
, height
を指定すればcanvasのサイズを設定できる。
wireframes
がtrueの場合はワイヤーフレーム表示になり、黒い背景にオブジェクトの輪郭線だけが描画される。ワイヤーフレーム表示では塗りつぶし色やテクスチャなどが反映されないので基本的にはfalseに設定しておく。
最後にRender.run(render)
でレンダリングを実行する必要がある。
物理演算オブジェクトに対するマウス入力と操作を設定する。
マウス制約MouseConstraint
は主にオブジェクトをドラッグ移動させる時の挙動などを設定するモジュールである。
MouseConstraint
インスタンス生成時のオプションでangularStiffness
を設定しているが、これはオブジェクトをマウスで掴んだ時の回転角度の補正速度を設定するものである。0〜1の間で設定するようで、1は回転なしで0に近いほど回転速度が大きくなる。(この辺りあまり公式ドキュメントに書かれていないので正確ではない)
mouseConstraint.body
とすれば、ドラッグ中のオブジェクト(Body
オブジェクト)を参照することができる。
また、Event
モジュールのon()
でマウス制約を指定すればマウスイベントを設定することもできる。ここではmousemove
イベントにマウスの座標を取得する処理をバインドしている。
マウス制約やオブジェクトなどは基本的にComposite
モジュールで演算世界へ投入していく。
Composite.add(engine.world, [投入するもの])
のようにする。
投入するオブジェクトが1個だけの場合はそのまま第2引数に渡せば良いが、複数の場合は配列にして渡す必要がある。
画面のテンプレートとメイン処理
まずは画面のテンプレートのほうから。
プルダウンやチェックボックスなどのUI部分を除けば、主に必要なのは<canvas id="texture-canvas" width="50" height="50"></canvas>
、<div id="matter-canvas-area"></div>
である。
canvas#texture-canvas
要素は選択したアイコン画像を表示するとともに、演算用オブジェクトに貼り付けるテクスチャとしても使用する。
div#matter-canvas-area
要素はmatter.js初期設定のところでRender
のelement
に指定した要素である。このdiv
の内部に物理演算用canvasが挿入される。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Poke physics calc</title> </head> <body> <div id="control-area"> <div id="icon-control"> <div id="icon-select"> <select> <option hidden>選択してください</option> </select> <div id="texture-container"> <!-- 初期サイズは50x50 --> <canvas id="texture-canvas" width="50" height="50"></canvas> </div> </div> <label for="is-random"><input type="checkbox" id="is-random">ランダム生成</label> </div> <div id="object-control"> <div id="left-container"> <label for="is-display-walls"><input type="checkbox" id="is-display-walls" checked>左右の壁</label> <label for="is-display-ground"><input type="checkbox" id="is-display-ground" checked>地面</label> </div> <div id="right-container"> <span>その他のオブジェクト</span> <label for="none"><input type="radio" id="none" value="none" name="equipment" checked>なし</label> <label for="slope"><input type="radio" id="slope" value="slope" name="equipment">スロープ</label> <label for="cup"><input type="radio" id="cup" value="cup" name="equipment">カップ</label> <label for="hourglass"><input type="radio" id="hourglass" value="hourglass" name="equipment">砂時計</label> </div> </div> </div> <div id="icon-container"> <!-- 画面表示時にiconの元画像を全て読み込んでimg要素を格納する --> </div> <div id="matter-canvas-area"></div> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script src="./main.js"></script> <script src="https://docs.opencv.org/4.6.0/opencv.js"></script> </body> </html>
次はindex.js
のメイン処理(画面ロード時の処理)である。
ここでは主にアイコンオブジェクトを生成するのに必要な処理について書いている。(デモページには左右の壁や床、下端に壁や床などが存在しているがそれらの生成処理は省略)
matter.js初期設定用のbasicMatterConfig()
やopencv.js関係のgetVerticesFromImageSrc()
はここで読み込んでおく。
アイコン選択プルダウン生成用のname_data.json
もここで読み込む。(webpackのjson-loaderを利用している)
アイコン選択プルダウンiconSelect
が変更されたらsetIconObject()
を実行し、物理演算用canvasmatterCanvas
がクリックされたらaddIconObject()
を実行する処理にしている。
import { basicMatterConfig, mousePointer } from './use_matter'; import { getVerticesFromImageSrc } from './use_opencv'; import './style.css'; import nameData from './json/name_data.json' // matter.js関連のモジュール格納用 let Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint; // アイコンテクスチャcanvas、物理演算canvas要素 let textureCanvas, matterCanvas; // アイコンの輪郭の座標値の配列 let vertices = []; // 物理演算を行うcanvas領域のサイズ const matterWidth = 900; const matterHeight = 600; $(document).ready(() => { textureCanvas = $('#texture-canvas')[0]; matterCanvas = $('#matter-canvas-area')[0]; const iconSelect = $('select')[0]; const isRandom = $('#is-random')[0]; // 名前情報オブジェクトの配列 const names = nameData['names']; const iconContainer = $('#icon-container')[0]; $.each(names, (i, data) => { // 名前情報からアイコン選択プルダウンのoptionを生成する $(iconSelect).append(`<option value="${data.id}">${String(data.id).padStart(3, 0)}: ${data.name}</option>`); //アイコン選択時に毎回アイコン画面を読み込んでいると時間がかかるので画面表示時に全て読み込んでimg要素を生成しておく $(iconContainer).append(`<img id="${i + 1}" src="./image/${i + 1}.png">`); }); // matter.jsの基本設定 ({ Body, Bodies, Bounds, Constraint, Composite, engine, mouseConstraint } = basicMatterConfig(matterCanvas, matterWidth, matterHeight)); // 生成するアイコンの選択 $(iconSelect).on('change', () => { const id = $('option:selected').val(); const imgElement = $(`img[id='${id}']`)[0]; setIconObject(imgElement); }); $(matterCanvas).on('click', () => { // オブジェクトのドラッグ中は新規でオブジェクトを追加させない if (mouseConstraint.body) { return }; if ($(isRandom).is(':checked')) { // ランダムにオブジェクトを生成する const id = Math.floor(Math.random() * names.length) + 1; $(iconSelect).prop('selectedIndex', id); const imgElement = $(`img[id='${id}']`)[0]; setIconObject(imgElement); } // 頂点座標が生成されていない場合は新規でオブジェクトを追加させない if (vertices.length === 0) { return }; addIconObject(); }); });
アイコンオブジェクトの生成
アイコン画像の輪郭の座標抽出は前述のopencv.jsの処理になるので、アイコンのimg
要素をgetVerticesFromImageSrc()
に放り込むだけだ。
この後は輪郭の座標値vertices
から、アイコンを内包する長方形の境界bounds
を生成しテクスチャの切り出しに用いる。
しかし、 どうも実際のアイコンの輪郭よりも若干狭いbounds
が生成されるので、テクスチャを切り出すと上下左右の端が少し切れてしまっていた。そこで、一旦生成されたborder
を任意の値で少し広げてやることにした(透過背景なので切り取り領域が広い分には見た目への問題はない)。
これは推測だが、ポケモンのアイコンは全て黒い線で縁取りされており、透過背景の黒(0, 0, 0)に極めて近い色なのでグレースケールや二値化により縁取り線も背景の一部とみなされて輪郭が抽出されているのではないかと思う。つまり、実際のアイコンの輪郭よりも縁取りの幅分だけ少し狭くなるのかもしれない。
const setIconObject = (imgElement) => { // アイコンの輪郭の頂点座標を取得する vertices = getVerticesFromImageSrc(imgElement); // 輪郭の頂点を内包する矩形の境界線を生成 const bounds = Bounds.create(vertices); // 境界を少し広げて元のアイコン画像の欠損を防ぐ const factor = 5; bounds.min.x -= factor; bounds.max.x += factor; bounds.min.y -= factor; bounds.max.y += factor; textureCanvas.width = bounds.max.x - bounds.min.x; textureCanvas.height = bounds.max.y - bounds.min.y; // 境界線で切り取ったアイコン(テクスチャ)をcanvasに描画する const textureContext = textureCanvas.getContext('2d'); textureContext.drawImage( imgElement, bounds.min.x, bounds.min.y, textureCanvas.width, textureCanvas.height, 0, 0, textureCanvas.width, textureCanvas.height); };
アイコンオブジェクトの投入
Bodies
(Body
とは異なる)は長方形rectangle()
、円circle()
、正多角形polygon()
といった簡易な図形を生成する関数の他に、頂点の座標値を与えて複雑な形状の図形を生成するfromVertices()
があるのでここではこれを使う。
ただし、fromVertices()
で作成したオブジェクトは凹面を表現することができず自動的に埋められたような形状で生成されてしまうという欠点がある。ワイヤーフレーム表示にしてみると一目瞭然だ。

この対策としてmatter.jsの公式ドキュメントではpoly-decomp.js
というパッケージの使用が推奨されているのだが、いかんせん使いにくいし満足な結果が得られなかった。(補足に使い方を記載した。)
結局今回は凹面が埋められた形状で妥協することにした。細かく輪郭を抽出した意味が薄れるが、長方形の境界で物理演算されるよりは良いしアイコンオブジェクト自体が小さいので目をつぶった。
const addIconObject = () => { const pokeIcon = Bodies.fromVertices(mousePointer.x, mousePointer.y, vertices, { render: { sprite: { texture: textureCanvas.toDataURL(), } }, friction: 0.01, restitution: 0.5, }); Composite.add(engine.world, pokeIcon); };
完成
良い感じに投入できるようになった🎉

砂時計状のオブジェクトなども入れてみた。

補足〜凹面を含むオブジェクトについて〜
matter.jsのfromVertices()
では凹面を表現できないという落とし穴があった。どうしても凹面まで表現したい場合はpoly-decomp.js
を導入する方法がある。
poly-decomp.js
は、npm
を利用するかdecomp.js
をダウンロードしてhtmlファイルからscript
タグで読み込めば挿入することができる。
凹面を含む図形を、凹面が含まれないように複数の図形に分割し、それらをパーツとして結合したオブジェクトにしてくれる。
複数のパーツを結合したオブジェクトであれば結果的に凹面になっても演算に考慮してくれる。
しかしさらに落とし穴があり、それで生成されたオブジェクトにテクスチャを適用すると1個1個のパーツ全てに同じテクスチャが貼られてしまう。

これを防ぐためにソースコードの修正が必要になる。(matter.js
のRender.bodies
を定義しているところ)
// handle compound parts for (k = body.parts.length > 1 ? 1 : 0; k < body.parts.length; k++) { part = body.parts[k];↓
// handle compound parts const parts = body.render.sprite.single ? 1 : body.parts.length; for (k = !body.render.sprite.single && body.parts.length > 1 ? 1 : 0; k < parts; k++) { part = body.parts[k];さらに、
index.js
のaddIconObject()
のオブジェクト生成時のrender.sprite
オプションにsingle: true
を追加する。
render: { sprite: { texture: originCanvas.toDataURL(), single: true, } },
これでOKだが、前述の通りポケモンアイコンの輪郭に適用しても良い感じにならなかった。単純な図形なら綺麗に分割できるのだが、今回のポケモンアイコンでは分割後の輪郭が元と比べて欠けていたり複数領域にちぎれたようになっていたりして上手い具合にならなかった。複雑な形状には適していないのだろうか。
参考
opencv.js関係
matter.js関係