この記事は画像スライドパズルのエンジニアリング面について——任意の写真がどのようにして遊べるN×Nパズルになるかを扱います。プレイヤーには見えない部分ですが、開発者が想定以上の時間をかける部分です。
パイプライン
3つの段階、順番に:
1. 正方形トリミング
ほとんどの写真は正方形ではありません。iPhoneのHEICファイルは4:3、縦向きで撮ったiPhoneの写真は3:4です。スライドパズルの盤面は1:1です。最初のステップは、写真のどの正方形領域を使うかを選ぶことです。
一般的な2つのアプローチ:
中央トリミング。 収まる最大の中央正方形を取る。高速で予測可能ですが、被写体が中央にないと間違えることがある。
インタラクティブトリミング。 ドラッグ可能な正方形のオーバーレイをユーザーに見せる。選んでもらう。遅いですが、ユーザーは常に望むものを得られます。
Slide Puzzleはインタラクティブトリミングを使い、デフォルトは中央です。トリミングは非破壊的——ユーザーのフォトライブラリにある元のファイルは変更されません。
2. 作業解像度へのリサイズ
トリミングした後も、画像は通常3000×3000以上です(カメラによる)。パズルのプレイにはオーバースペックです。3× iPhoneで320ptに表示される6×6の盤面では、各タイルが約53pt = 160デバイスピクセルでレンダリングされます。1024×1024のソース解像度で、タイル1枚あたり約170ピクセルになります——ディスプレイが表示できる以上のシャープさです。
1024(または2048)へのリサイズが標準です。それ以上大きいソースはメモリを無駄にし、読み込みを遅くするだけで、見た目の改善はありません。
3. オンデマンドタイルレンダリング
これが最初の試みでほとんどの人が間違える部分です。画像は実際に16個の別ファイルには切り取られません。 それは遅く、無駄で、不要です。
代わりに、アプリは各タイルを同じソース画像をタイルサイズの矩形に描画することでレンダリングします。ソース矩形のオフセットをゴール画像内のタイル位置に合わせて。CSSでは「background-position」のトリック、iOSではCGImageのクリップ矩形、SwiftUIではImageに対してRectangle().clipped()を使います。
画像の辺をSとしたN×NのパズルのゴールPosition(行、列)にあるタイルを描画する疑似コードで:
draw image at (-col * S/N, -row * S/N)
within a clip rectangle of (S/N × S/N)
これだけです。4×4では16タイル——つまり同じソース画像に対して16種類の異なるオフセットで16回描画呼び出し。アプリはファイルを切り取る必要がありません。
これが6×6の盤面のスライスが瞬時に行われる理由でもあります:36回のファイル操作ではなく、同じレンダラーへの36回の呼び出しです。
アニメーションについて
タイルをスライドさせることは、レンダリングされたタイルへのトランスレート変換です。タイル内の画像コンテンツは変わりません——変わるのは画面上のクリップ矩形フレームの位置だけです。つまりスライドアニメーションはGPU的に軽くて、ProMotionディスプレイで120 Hzで動作します。
重要な3つの実装の詳細:
- クリップとコンテンツは親子ではなく兄弟です。 ネストされていると、コンテンツがクリップと一緒に動いてスライドが壊れます。
- レイアウトではなくトランスフォームを使う。 CSSのtransformまたはSwiftUIのoffsetでx/yをアニメーションさせるのはGPUアクセラレートされますが、left/topのアニメーションはそうではありません。
- 初回表示時に事前ラスタライズする。 タイルレンダリングの最初のフレームは、ソース画像がデコードされるため遅くなる可能性があります。ゲーム開始時に全タイルを一度レンダリングしてウォームアップしてください。
ストレージ
インポートした画像は実際にどこに保存されるのか?
プライバシーを尊重するアプリでは、iOSによって保管時に暗号化されたアプリのサンドボックスの中に。フォトライブラリには引き続きオリジナルが保持され、アプリはアプリ自身のDocumentsフォルダ内に1024×1024の作業コピーを保存します。ユーザーがアプリを削除すると、作業コピーも一緒に削除されます。
クラウドベースのアプリでは、作業コピーはどこかのサーバー上に存在します。プライバシー、オフラインプレイ、そしてサーバーが消えたときに何が起きるかという点で、影響はまったく異なります。Slide Puzzleはサンドボックス型です。
メモリ予算
典型的な4×4の画像スライドパズル:
- ソース画像:約3 MB JPEG。
- メモリ内でデコードされた画像:1024 × 1024 × 4バイト = 4 MB。
- アクティブなゲーム1つにつき1コピー。
これは問題ありません。6×6でも、ソース画像とデコードバッファのサイズは同じです。盤面はより多くのメモリを必要としません。
メモリ予算が逼迫するのはカバーライブラリの場合です——300枚のカバー × 4 MB = すべてを一度にデコードすると1.2 GB。アプリはオンデマンドデコード(カバーが表示されるときのみ)とバックグラウンド時の解放(アクティブなゲームの画像のみメモリに保持)でこれを回避します。
エッジケース
実際に問題になる3つのこと:
EXIFオリエンテーションタグ付きの写真。 縦向きに撮影されたが横向きとして保存されていて回転タグがある写真は、フォトライブラリでは正しく表示されますが、アプリがEXIF回転を適用し忘れるとパズルで正しく表示されません。すべてのフォトパズルアプリの最初のバージョンにはこのバグがあります。
非常に大きなソース画像。 HEICファイルには6000×8000ピクセルのものもあります。これをフルサイズでメモリに読み込むと、小さいiPhoneでアプリがクラッシュします。解決策はダウンサンプリングされた目標サイズへのストリーミングデコードです——AppleのImageIOはこれをサポートしています。2048×2048でディスクから直接デコードし、フルサイズではデコードしない。
サブピクセルレンダリング。 タイルサイズが画面のピクセルグリッドに均等に割り切れない場合、各タイルの片側に1ピクセル未満の列が生じます。純粋なクリッピングでは、これが細い隙間として表示されます。対処法は、タイルサイズを整数ピクセルにスナップする(非標準の盤面サイズでジッターが見える)か、タイルを0.5ピクセル重ねる(隙間もジッターもなし)かのどちらかです。
これらは深い問題ではありませんが、最初に実装する人には漏れなく忘れられます。
まとめ
パイプラインは:トリミング → リサイズ → クリップ+トランスレートでタイルをレンダリング → トランスレート変換でスライドをアニメーション。プライバシーを尊重するアプリでは画像がデバイスから出ません。パイプライン全体が約200行のコードに収まります。さらに50行はEXIF回転の処理——最初に誰もが忘れるもの——です。
画像スライドパズルは外から見た印象より単純です。画像は1枚のまま、タイルはただそのフレームビューです。