この記事の要点
- Swift は Apple プラットフォームのアプリ開発言語として知られていますが、メモリ安全性と高い使い勝手を保ちつつ、C / C++ が使われてきた領域も狙えるマルチプラットフォーム言語へと成長してきました。本記事は、その実証として Panic の携帯ゲーム機 Playdate 向けゲームを Swift で開発した 記録です。成果物は swift-playdate-examples として公開されています。
- Playdate は Cortex-M7 プロセッサ、400×240 の 1-bit ディスプレイを備えた小型ゲーム機で、リソース制約が厳しく通常の Swift ランタイムは収まりません。そこで Embedded Swift(embedded language mode)を使います。generic specialization・inlining・dead code stripping によって、Swift の中核機能を保ったまま極小のバイナリを生成できます。
- 2 本のゲームを作成しました。Conway のライフゲームの移植版は、動的メモリ確保なしで Playdate C API を直接呼び出し、パッケージ後 788 バイト(C 版の 904 バイトよりわずかに小さい)に収まりました。もう 1 本「Swift Break」は、関連値を持つ
enum・ジェネリクス・自動メモリ管理といったデスクトップ/サーバーと同じ高レベル機能を使いつつ、C 並みの性能を実現しています。 - 後半は、新しいプラットフォームへ Swift を持ち込む過程の技術的な深掘りです。Playdate C SDK への相互運用、シミュレータと実機での動作、API notes と Swift オーバーレイによる使い勝手の改善、そして呼び出し規約・CPU 設定・enum のメモリレイアウトといった移植時のハマりどころが具体的に語られます。
背景: なぜ Embedded Swift なのか
Playdate のゲームは開発のしやすさから多くが Lua で書かれますが、性能上の問題から C の利用が必要になることがあります。Swift は高レベルな使い勝手と低レベルな性能を併せ持ち、C との相互運用も得意なため、Playdate と相性が良さそうに見えます。しかし、通常の Swift アプリとランタイムは、この機種の厳しいリソース制約を超えてしまいます。
そこで使うのが Embedded Swift です。Swift プロジェクトが制約の厳しいプラットフォーム向けに開発を進めている言語モードで、次の特徴により極小のバイナリを生成します。
- generic specialization(ジェネリクスの特殊化)
- inlining(インライン化)
- dead code stripping(dead code elimination)
これらにより Swift の中核機能を保ったまま、Playdate の制約に収まるサイズへ縮小できます。Embedded Swift は活発に進化しており、non-copyable types(SE-0390) や typed throws(SE-0413) といった言語機能の開発も後押ししています。
作ったゲーム
Conway のライフゲーム
Playdate SDK のサンプルを Swift へ移植した 1 本目です。Swift ファイル 1 つで Playdate C API に対して直接ビルドでき、動的メモリ確保を必要としません。Playdate OS が提供するフレームバッファ上で直接動作し、ディスプレイ自体をゲーム状態として使うため、別途のデータ構造や動的確保が不要になっています。
C 版のビット操作・ポインタ操作には Swift の直接的な対応物があるため、移植は機械的に進みました。たとえば、あるビットが立っているかを調べる処理は次のように書けます。
struct Row {
var buffer: UnsafeMutablePointer<UInt8>
func value(at column: Int32) -> UInt8 {
isOn(at: column) ? 1 : 0
}
func isOn(at column: Int32) -> Bool {
let byte = buffer[Int(column / 8)]
let bitPosition = 0x80 >> (column % 8)
return (byte & bitPosition) == 0
}
}
パッケージ後のサイズは 788 バイトで、C のサンプル(904 バイト)よりわずかに小さくなりました(どちらもサイズ最適化は追求していません)。
Swift Break
2 本目はパドルとボールのゲーム「Swift Break」です。スプラッシュ画面・ポーズメニュー・パドル位置に応じたバウンド物理・無限ステージ・ゲームオーバー画面を備え、十字キーまたはクランクでパドルを操作できます。
このゲームは、デスクトップやサーバーのアプリと同じ高レベル機能(関連値を持つ enum、ジェネリックな型・関数、自動メモリ管理)を活用してゲーム開発を簡潔にしつつ、C レベルの性能を保っています。ボールの跳ね返り処理の中核は次のようなコードです。
sprite.moveWithCollisions(goalX: newX, goalY: newY) { _, _, collisions in
for collision in collisions {
let otherSprite = Sprite(borrowing: collision.other)
// If we hit a visible brick, remove it.
if otherSprite.tag == .brick, otherSprite.isVisible {
otherSprite.removeSprite()
activeGame.bricksRemaining -= 1
}
var normal = Vector(collision.normal)
if otherSprite.tag == .paddle {
// Compute deflection angle (radians) for the normal in domain
// -pi/6 to pi/6.
let placement = placement(of: collision, along: otherSprite)
let deflectionAngle = placement * (.pi / 6)
normal.rotate(by: deflectionAngle)
}
activeGame.ballVelocity.reflect(along: normal)
}
}
深掘り: 新しいプラットフォームへ Swift を持ち込む
著者の方針は、Swift の相互運用性を活かして Playdate C SDK の上に構築することでした。必要な機能はツールチェインに既に揃っており、あとはそれらをどう組み合わせるかという問題でした。主なステップと、つまずいたポイントを順に見ていきます。
C API を import する
Playdate C SDK のヘッダを Swift から使うには、まず module map を書いてヘッダを import 可能なモジュールにまとめます。
module CPlaydate [system] {
umbrella header "pd_api.h"
export *
}
そのうえで、include 検索パス(-I)でヘッダの場所を、import 検索パス(-I)でモジュールの場所を Swift コンパイラに伝え、ヘッダのパースに必要な define(-DTARGET_EXTENSION)を渡します。Embedded Swift を有効にするには -enable-experimental-feature Embedded、サイズ最適化には -Osize -wmo を指定します。
シミュレータで動かす
シミュレータはホスト用ライブラリを動的にロードして動作するため、ホストのプラットフォーム・アーキテクチャ向けにオブジェクトファイルをビルドします(swiftc の既定の挙動)。ライフゲームを移植して pdc(Playdate コンパイラ)で pdx(Playdate 実行形式)にパッケージしたところ、最初はクラッシュしました。
原因は、ゲームを起動するためのシンボル _eventHandlerShim を含む setup.c(SDK 付属)をリンクし忘れていたことでした。このシンボルが無いとシミュレータは _eventHandler でフォールバック起動してしまい、重要な初期化がスキップされていました。setup.c を clang でオブジェクトファイルにコンパイルしてリンクすると、無事に動作しました。
実機で動かす
実機向けには、まずデバイスのターゲットトリプル(armv7em-none-none-eabi)を指定します。すると C 標準ライブラリのヘッダ(stdlib.h など)が見つからずエラーになります。これはホスト向けビルドではホストのヘッダが使われていたためで、C のサンプルにならって Playdate SDK 同梱の gcc ツールチェインの libc ヘッダの include パスを追加して解決しました。
実機でもクラッシュしましたが、過去の Cortex-M7 への Swift 展開経験から 呼び出し規約(C calling convention)の不一致 だと見当が付き、-Xfrontend -experimental-platform-c-calling-convention=arm_aapcs_vfp を加えて Playdate OS の呼び出し規約に合わせると、ライフゲームが実機で動きました。最終的に、手作業のコンパイル手順を Playdate SDK の Makefile に統合し、make 一発でシミュレータと実機の両方に対応する pdx をビルドできるようにしています。
Swift で API を改善する
生の Playdate C API を Swift から直接使うのは扱いにくかったため、著者は API の使い勝手の改善に寄り道します。
1 つ目の課題は命名規則です。C の enum のケースは型名を接頭辞に付けがちですが、Swift では型チェックがあるため不要です。Swift には API notes(ヘッダを書き換えずに外付けでアノテーションを与える仕組み)があり、これを使ってケース名を Swift らしく改名できます。
// Before
if event == kEventPause { ... }
// After
if event == .pause { ... }
2 つ目の課題はより深刻で、C API に nullability アノテーションが無いことでした。このため import されたコードに冗長な null チェックが入り、コードサイズと性能を悪化させます。通常なら API notes で補えますが、この C API は関数ポインタを持つ struct を「vtable」として使っており、これは現状 API notes で変更できません。そこで Optional.unsafelyUnwrapped を多用するという次善策を取らざるを得ず、可読性が大きく損なわれました。
// 冗長な null チェックが残る書き方
let spritePointer = playdate_api.pointee.sprite.pointee.newSprite()
// null チェックを取り除いた書き方(可読性が大きく低下)
let spritePointer = playdate_api.unsafelyUnwrapped.pointee.sprite.unsafelyUnwrapped.pointee.newSprite.unsafelyUnwrapped()
これを解決するため、C API の上に薄い Swift オーバーレイ を作りました。関数ポインタへのアクセスを Swift 型の静的/インスタンスメソッドに包み、get/set のペアをプロパティに変換することで、import された C 呼び出しと同等のままオーバーヘッドゼロで直感的に書けるようになります。
var sprite = Sprite(bitmapPath: "background.png")
sprite.collisionsEnabled = false
sprite.zIndex = 0
sprite.addSprite()
さらに、手動のメモリ管理が必要な API も自動化しました。たとえば C API の moveWithCollisions は、呼び出し側が解放すべき SpriteCollisionInfo のバッファを返しますが、オーバーレイ側でこれを隠蔽することで手動の解放が不要になります。
// オーバーレイなし
var count: Int32 = 0
var actualX: Int32 = 0
var actualY: Int32 = 0
let collisionsStartAddress = playdate_api.pointee.sprite.pointee.moveWithCollisions(sprite, 10, 10, &actualX, &actualY, &count)
let collisions = UnsafeBufferPointer(start: collisionsStartAddress, count: count)
defer { collisions.deallocate() }
for collision in collisions { ... }
// オーバーレイあり
sprite.moveWithCollisions(goalX: 10, goalY: 10) { actualX, actualY, collisions in
for collision in collisions { ... }
}
著者は、Swift の ownership や non-copyable のサポートが進めば、言語側のオーバーヘッドなしにさらに使い勝手の良い C API 表現が可能になると見込んでいます。
実機で動かす(再び)
Swift Break を実機で動かそうとすると、再びクラッシュしました。前述の -Xfrontend フラグだけでは呼び出し規約の問題が完全には解決しておらず、マイクロコントローラの CPU と浮動小数点 ABI に合わせてコンパイラを構成する必要がありました(ライフゲームでは struct の値渡しも浮動小数点演算も使っていなかったため表面化しませんでした)。
最も厄介だったのは、Playdate OS から enum を返す API 呼び出しでのクラッシュです。原因は、gcc でビルドされたシステムと swiftc(内部の clang)でビルドしたゲームとの間で enum のメモリレイアウトが食い違っていた ことでした。gcc は既定で -fshort-enums を使う一方、armv7em-none-none-eabi トリプルでは swiftc 経由の clang は -fno-short-enums を使っていたためです。-Xcc -fshort-enums をはじめ、CPU・FPU・浮動小数点 ABI を指定するフラグ群を加えて、ようやく Swift Break が実機で動作しました。
まとめ
呼び出し規約・CPU 設定・メモリレイアウトの食い違いといった移植時の難所は多くありましたが、いったん解決すれば、Playdate 向けゲームの Swift 開発は make 一発で済む快適なプロセスになります。本記事は、Swift がメモリ安全性と表現力を保ちながら、極めて制約の厳しい組み込み環境にも持ち込めることを示す実例です。コードと「Getting Started」ドキュメントは swift-playdate-examples で公開されています。
関連リンク
- swift-playdate-examples — 本記事で紹介された Playdate 向け Swift ゲームのサンプルとセットアップ手順
- A Vision for Embedded Swift — Embedded Swift の構想
- non-copyable types(SE-0390) / typed throws(SE-0413) — Embedded Swift が開発を後押しした言語機能
- Clang API Notes — ヘッダを書き換えずに C API へアノテーションを与える仕組み
- C API を Swift らしく見せるテクニックは SwiftでのCライブラリの使い勝手を改善する でも詳しく扱っています。