数字ぬりえアプリは輪郭をどう保存しているのか
ベクターでもバケツ塗りでもありません。画素の色そのものが領域番号になっている二枚目の画像と、12ピクセルの色の帯と、GPUのシェーダーです。
ぬりえアプリが1枚のあいだに何千回も答えなければならない問いは二つだけです。いま押されたのはどの領域か、そしてこの画素はいま何色であるべきか。どちらも即座に答えが出なければなりませんし、キャンバスは二百万画素を超えていて、動いているのはスマートフォンで、しかもバッテリーに気づかれてはいけません。
直感的に浮かぶ二つのやり方はどちらも成り立たず、実際に成り立つやり方は、そのどちらよりも面白い作りをしています。
思いつきやすい答えが通らない理由
ベクター図形
領域ごとに閉じたパスとして持つのは自然な発想ですし、元の絵はおそらくそう描かれています。ただし緻密な曼荼羅には数百の領域があり、境界はどれも入り組んでいます。すると一度のタップの当たり判定は、その点と数百本の複雑なパスとの内外判定になり、描画のほうはGPUが毎フレームその全部をラスタライズすることになります。絵が緻密になるほど悪化する——そして絵が向かう方向はまさにそちらです。
バケツ塗り(flood fill)
押した画素から外へ広げ、境界に当たったら止め、通った範囲を塗る。ペイントソフトのやり方ですが、ここでは二つの理由で間違いです。まずタップのたびに広い面積を走査するので、いちばん遅くなってほしくない瞬間に遅くなります。そして脆い。輪郭に1画素の隙間があるだけで塗りが漏れ出し、隣の領域まで一緒に埋めてしまいます。この種の不具合は無事に出荷され、そのあと特定の一枚でだけ姿を現します。
答えは、領域番号を画像に入れてしまうこと
領域を記述するのではなく、あらかじめ計算しておいて、結果を元絵と同じ大きさの二枚目の画像として持ちます。その画像の画素はどれも、その画素が属する領域の番号を持っています。
1画素には使える色チャンネルが8ビット3本あり、領域番号には256を超える値が要るので、番号は2本に分けて入れます。Numbrush では赤が上位バイト、緑が下位バイトで、合わせて16ビットの番号になります。そして青が255なら、その画素は輪郭の一部です。番号0は背景です。
このファイルは可逆でなければなりません。PNGであって、決してJPEGではありません。非可逆圧縮は、目が許してくれる範囲で画素の値をわずかに変えることで成り立っています。ところがここでの画素の値は色ではなく数です。番号の地図のJPEGというのは、ノイズを撮った写真であり、その中で番号が静かに互いへ丸められてしまったものです。
読み込み時に一度の線形走査で、RGBAのバイト列は16ビット番号の平らな配列になります。その瞬間から「この画素はどの領域か」は、計算した添字での配列の読み出し一回になります。探索でも走査でもなく、一回の読み出しです。
パレットもまたテクスチャ
このしくみのもう半分は、アプリが何を描けばよいかを知る方法です。絵はそれぞれ12色を持っていて、それが幅12ピクセル、高さ1ピクセルのテクスチャになります。各画素がパレットの1色を持ちます。
面白いのはこの帯の不透明度チャンネルで、これは透明度としてはまったく使われていません。ここに入っているのは状態のフラグです。ある値はこの色がここでは塗り済みであることを、別の値は未塗りであることを、もう一つの値はいま選択中で強調すべき色であることを表します。3つの状態が、余っていた1バイトに忍び込ませてあります。
両者を結ぶシェーダー
こうなると描画は、GPU上で画素ごとに、毎フレーム走る小さなプログラムになります。画面の各画素について、だいたい次のことをします。
- その位置で番号の地図を標本化し、赤と緑のバイトから16ビットの番号を組み立て直す。
- 青のバイトが輪郭だと言っていれば、ほとんど黒に近い線の色を描いて終わり。
- そうでなければ、その番号で12ピクセルの色の帯を引く。
- 引いてきたマスの不透明度バイトからフラグを読む。
- 塗り済みならパレットの色を、未塗りなら未塗りの下地を、いま選択中の色ならその強調表示を描く。
強調するかどうかがシェーダーの中で決まるので、選択中の色を切り替える費用はほとんどゼロです。12ピクセルのテクスチャの1バイトを書き換えるだけで、次のフレームにはキャンバス全体の見え方が変わります。3種類の強調のしかた——無地、市松、脈打つ輪郭——も同じことで、素材が3組あるのではなく、シェーダーの中の分岐です。
塗りが一瞬で終わる理由
ここが報酬です。ひとつの領域を塗るというのは、小さな状態の配列の1バイトを書き換え、そのうえで色の帯を作り直すこと。数キロバイトのテクスチャです。書き込みはこれで全部です。
二百万画素の番号の地図には一切触れません。読み直されることも、書き換えられることも、転送し直されることもありません。ビットマップが描き直されることもなければ、大きな確保も起きません。古い端末でも塗りが一瞬で終わり、1時間塗っても端末が熱くならないのはこのためです。高価なものは読み取り専用で、書き換わるものはごく小さいのです。
これを台無しにする二つの細部
このやり方には失敗のしかたが二つあります。どちらも分かりやすいクラッシュではなく壊れた領域として出てくるので、名前をつけておく価値があります。
一つ目はテクスチャの補間です。GPUは既定で隣り合う画素のあいだをなめらかにつなぎます。写真には正しく、番号には破滅的です。番号40と41のあいだを補間すると40.5になり、丸めた先の領域は絵のまったく別の場所にあるかもしれません。番号の地図は最近傍で標本化し、ミップマップを切らなければなりません。症状は分かりやすく、すべての境界沿いに違う色の細い縁が出ます。
二つ目はカラーマネジメントです。番号のテクスチャにカラースペースのタグを付けて転送すると、システムが変換をかけることがあります。そして数の並びに色変換をかければ、変わるのはその数そのものです。地図はタグを付けず、乗算済みにもせず、生のまま転送しなければなりません。症状は「どこもかしこも少しずつ違う」で、これは「どこか一箇所が明らかにおかしい」よりずっと診断が難しいものです。
番号の地図はどこから来るのか
地図は手描きではありません。イラストをまず拡縮し、輪郭を保つフィルターでならし、平坦な色面へ量子化し、そのうえでラベルを振ります。同じ色でつながっている面のひとつひとつが、番号を持つ領域になります。指で押すには小さすぎる領域や細すぎる領域は隣へ併合され、輪郭が彫られ、ラベルの位置も計算されます。番号が外接矩形の中心ではなく、その領域の見た目の中心に座れるようにするためです。
品質にとって大事なのは最後の工程です。検証の処理が、できあがったファイルをディスクから読み直し、書式の規則と突き合わせます。通らなかった絵は出荷されずに差し戻されます。壊れた番号の地図は目に見えず、誰かがちょうどその壊れた領域を押した瞬間まで表に出てこないからです。
よくある質問
なぜ領域ごとにSVGのパスにしないのですか
それだと一度のタップの当たり判定が、数百本の入り組んだパスとの内外判定になり、描画は毎フレームその全部のラスタライズになります。あらかじめ焼いておいた番号の地図なら、当たり判定は配列の一回の読み出しに、描画はテクスチャの引き表になります。どちらも絵の緻密さに関係なく定数時間です。
番号の地図をJPEGにできないのはなぜですか
画素の値が色ではなく数だからです。非可逆圧縮の値の変え方は、写真では気づけないものですが、データには致命的です。境界という境界で番号が隣と混ざります。PNGのような可逆の形式である必要があります。
16ビットの番号でいくつの領域を表せますか
背景を別にして65535まで表せます。指で押せる絵に必要な数をはるかに超えています。Numbrush の絵は数百領域という規模です。それ以上細かくすると、番号で表せなくなるより先に、押せなくなってしまうからです。
アプリは塗り終えた画像を保存しているのですか
していません。そこが要点です。進み具合は領域ごとに1バイト——塗ったかどうかと、どの色か——だけで、目に見えている絵はそれと番号の地図から毎フレーム作られています。1枚を保存するというのは数百バイトを保存することであって、画像を保存することではありません。
実際のキャンバスで試す
Numbrush は大人のための数字ぬりえです。曼荼羅、バラ窓、幾何学模様。1枚につき12色、狭いところには拡大鏡、押し間違えても失うものはありません。無料、オフライン、広告なし、アカウントなし。