画像アセット0枚。Jetpack ComposeのCanvasだけで「世界一どうでもいい30秒」のプリンを揺らし続ける技術
日々の開発タスクや理不尽なデバッグ作業で、心がカサカサに乾いてしまってはいないだろうか。自分は荒んでいた。そんな乾いた心に今こそ必要なのは、ぷるぷると揺れるプリンの圧倒的な存在感だ。そう思い立ち、端末を傾けてお皿の上のプリンを滑り落とさないように耐え抜く水平維持アクションゲーム「JigglyPudding」を開発した。キャッチコピーは「世界一どうでもいい30秒。」。しかし、このどうでもいい30秒を実現するために、裏側では狂気とも言える「無駄な技術的こだわり」を山ほど詰め込んでいる。今回は、ComposeのCanvas描画と超低遅延物理演算の沼について、その裏側をあますところなく語ってみたい。
なぜ3Dや画像ではないのか?Canvasによるベクターグラフィックスのこだわり
このゲーム、画面に表示されるプリンや白磁のお皿、カラメルだれのしなり、さらには落下した際に飛び散るカスタードの飛沫に至るまで、画像アセットを一切使用していない。すべてJetpack Composeの「Canvas」APIを駆使し、数式とコードだけで描画している。普通ならBlenderで3Dモデルを作りUnityで動かすか、PNG画像を並べて変形させるところだろう。だが、パッケージサイズを極限まで削りたかったことと、なにより「プログラムで描かれたプリンが物理法則に従って揺れる」という事実にロマンを感じてしまったからだ。
しかし、AndroidのCanvas描画で毎フレームの描画処理をそのまま実行すると、頻繁なオブジェクト生成によるGC(ガベージコレクション)が発生し、一瞬のジッター(カクつき)を招く。プリンの滑らかな質感において、カクつきは致命的だ。そこで、描画に関わるオブジェクトをあらかじめキャッシュする専用の構造体を実装し、描画ループ中のアロケーションを完全にゼロに抑え込むアプローチをとった。
/**
* 毎フレームのアロケーションを避けるための描画キャッシュ。
* Pathはrewind()で内部バッファを維持したまま使い回す。
*/
class PuddingDrawCache {
val custardPath = Path()
val pleatPath = Path()
val caramelPath = Path()
val caramelThicknessPath = Path()
val dripPath = Path()
val highlightPath = Path()
val rimLightPath = Path()
// プリーツ角(0..180度を8分割)の事前計算
val pleatCos = FloatArray(9) { cos(PI * it / 8.0).toFloat() }
val pleatSin = FloatArray(9) { sin(PI * it / 8.0).toFloat() }
// 雫(カラメルのたれ)の初期配置パラメータを決定的に生成
val dripCos = FloatArray(DRIP_COUNT)
val dripSin = FloatArray(DRIP_COUNT)
// ... 中略 ...
}
この設計により、プリンの輪郭やプリーツ(ひだ)の波打つパス、カラメルの粘性を表現するベジェ曲線の制御点を、描画ループが走るたびに再生成するのを防いでいる。プリンの底面のプリーツはあらかじめ8分割した正弦・余弦テーブルをプリコンパイルし、描画時に物理状態のねじれベクトルを乗算して歪ませている。さらに、カラメルソースが側面に垂れる「ドリップ」の形状も、起動ごとに変化しないよう決定的な疑似乱数 `rnd()` でパラメータ(滴る長さや先端の球体半径)を初期化時に確定させてキャッシュしている。無駄なアロケーションを防ぎ、滑らかなベクターグラフィックスを維持するためには必須のトレードオフだったと思う。
コルーチンの遅延を許さない。withFrameNanosによる超低遅延物理演算
プリンのリアルな「ぷるぷる感」を実現するために、物理エンジンも自作した。物理モデルは「4層のチェーンばね結合モデル」だ。お皿の上の底面(ディスク)の摩擦・慣性スライドに加え、下層、中層、上層(カラメル部分)をそれぞれ独立した質点として扱い、それぞれをフックの法則に基づく弾性ばねとダンピング(減衰)で数珠つなぎに結合している。さらに、プリンが左右に激しく引っ張られたり揺れたりしたとき、体積を維持しようとして縦方向にペシャンと潰れるような変形特性も、以下の物理更新コードで処理している。
// 左右の変形エネルギーから、押しつぶされるZ方向の縮み(lateralEnergy)を算出
val lateralEnergy = (currentState.def3VelX * currentState.def3VelX + currentState.def3VelY * currentState.def3VelY) * 0.012f
// 上層(レイヤー3)のZ方向(縦揺れ)の力。揺れが大きいほど押しつぶされる力が働く
val forceZ3 = -fShakeZ * 180f - lateralEnergy - kSpring * 0.8f * currentState.def3Z - cDamping * 0.8f * currentState.def3VelZ + (currentState.def2Z - currentState.def3Z) * kInter
この物理演算をどのようにComposeのUI描画と動かすかが最大の難所だった。最初、安易に `LaunchedEffect` 内で `delay(16ms)` などのコルーチンループを回して物理演算を呼び出していた。しかし、Androidのコルーチンはスレッド切り替えやスレッドプールへのディスパッチによって数ミリ秒の遅延が不規則に発生する。これが原因で、物理演算のタイムステップ `dt` が毎フレーム微妙にばらつき、肉眼でもはっきりわかるほどの不自然なガクつき(ジッター)が発生した。
プリンのぷるぷる感において、ジッターは文字通り「致命傷」になる。そこで自分は、コルーチンの非同期実行をあきらめ、ディスプレイの垂直同期信号(VSYNC)に同期してUIスレッドで直接実行される `withFrameNanos` を採用した。Choreographerの描画フレームが更新されるタイミングのナノ秒単位のタイムスタンプを受け取り、前フレームとの厳密な差分時間を算出して物理エンジンに渡す。これによって、物理演算とComposeのCanvas描画プロセスが同一スレッド上で100%同期し、完全にカクつきのない滑らかなプリンの揺れを手に入れることができた。
デバッグ中に遭遇した「60Hzハプティクス超連打事件」と解決策
本作の「水平維持アクション」としてのスリルを高めるため、お皿のフチに近い危険領域にプリンが滑り込むと、端末が振動してプレイヤーに危機を伝える「警告バイブレーション」の仕様を盛り込んだ。お皿のフチに近づくほど、バイブレーションのパルス間隔が短くなり、プレイヤーの焦りを煽るという設計だ。
しかし、テストプレイ中に深刻な問題が発生した。お皿のフチ付近にプリンがある際、端末が「ジジジジジッ!」とものすごい勢いで熱を持ちながら振動し続け、アプリが一瞬フリーズするような挙動を示したのだ。原因は、ハプティクスフィードバックを呼び出す `LaunchedEffect` の設計ミスだった。最初、自分は以下のように安易にプリンの位置(posX, posY)を `LaunchedEffect` の再起動キー(`key`)として設定していた。
// ダメな実装例:位置の変化をキーにしてしまったため、毎フレーム再起動される
LaunchedEffect(physicsState.posX, physicsState.posY) {
val ratio = sqrt(posX * posX + posY * posY) / plateRadius
if (ratio > 0.75f) {
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
val pulse = (140 - 90 * ((ratio - 0.75f) / 0.25f)).toLong()
delay(pulse) // 毎フレーム再起動されるため、このdelayが即座にキャンセルされてしまう!
}
}
物理演算は毎フレーム(約60Hz)位置を更新するため、`physicsState.posX` も毎フレーム変化する。その結果、この `LaunchedEffect` は内部の `delay(pulse)` に到達する前に、次のフレームの座標変化によって即座にキャンセル・再起動されてしまう。結果として `delay` は一切機能せず、毎秒60回もの高頻度で `performHapticFeedback` が連打され、システムの振動キューを埋め尽くしていたのだ。
このトラブルを解決するために、自分は `LaunchedEffect` の再起動キーをゲームプレイ状態(`AppMode.GAME_PLAYING`)のみに固定した。そして、キー変更による再起動を防ぎつつ、最新の物理状態を読み込むために `rememberUpdatedState` でラップした `latestPhysics` を作成し、その中で無限ループを回しながら自前でディレイを制御するようにした。
// 対策後の実装:キーはゲームモードのみ。内部で最新の物理状態を参照してループを回す
val latestPhysics by rememberUpdatedState(physicsState)
LaunchedEffect(uiState.mode) {
if (uiState.mode == AppMode.GAME_PLAYING) {
while (true) {
val s = latestPhysics
val ratio = sqrt(s.posX * s.posX + s.posY * s.posY) / plateRadius
if (ratio > 0.75f && !s.isFallen) {
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
// フチに近いほどパルス周期を短くして鼓動を速くする
val pulse = (140 - 90 * ((ratio - 0.75f) / 0.25f)).toLong().coerceAtLeast(50L)
delay(pulse) // ここでのディレイが正しく実行され、振動頻度が制御される
} else {
delay(80L) // 危険域外は低頻度で監視して負荷を下げる
}
}
}
}
これで `LaunchedEffect` は毎フレームキャンセルされることなく生存し続け、`delay` も正しく動作するようになった。ハードウェア制御が絡む部分で、安易に毎フレーム動く状態を `LaunchedEffect` のキーに設定することの怖さを痛感したトラブルだったが、Composeのライフサイクルと状態管理の仕組みを組み合わせることで綺麗に解決できた。
絶対水平への挑戦と、バカバカしさに全振りした落下ドラマ
こうして技術的に磨き上げられたプリンは、プレイヤーのささいな手ブレや端末の傾きによって、信じられないほど優雅に、そして理不尽に滑り落ちていく。お皿の自動傾斜は時間の経過とともに徐々に難易度を増し、30秒を耐えるだけでも「人間水準器」レベルの絶対的な水平感覚が要求される。難易度はEasy/Normal/Hardの3段階を用意しているが、Hardモードともなると、摩擦係数が極端に低下し、もはやお皿の上に氷を乗せているかのような滑りやすさになる。
そしてお皿からプリンが滑り落ちた瞬間の演出には、無駄なこだわりの中でも最大のエネルギーを費やした。落ちる瞬間、画面に大げさな「あーっ!!」という叫び文字が表示され、プリンは放物線を描いて「ぽーん」と一瞬宙に浮き上がってから、スローモーションで回転しながら落下していく。この「一瞬宙に浮く滞空時間」があることで、マンガのような滑稽な落下劇が生まれるんだよね。飛び散るカスタードの飛沫、そしてお皿の上に虚しく残されるカラメルの染み。このバカバカしい悲劇を見届けるためだけにプレイする価値があると思う。
一方で、この理不尽なゲーム性に疲れた人のために「鑑賞(Zen)モード」も搭載している。こちらはゲームオーバーがなく、画面のステータスバーやナビゲーションバーを完全に隠した状態で、ただプリンをつついて揺らしたり、端末を軽く揺すってプルプルする姿を眺めるだけのモードだ。トイピアノの心地よいループBGMも相まって、不思議なリラクゼーション効果を発揮してくれるはずだ。
自分の絶対水平感覚をテストしてみない?
「JigglyPudding」は、ゲーム性としては「世界一どうでもいい30秒」を競う極めてシンプルなカジュアルアクションだ。しかし、その裏側にあるアロケーションフリーのベクターCanvas描画や、`withFrameNanos` による超低遅延物理演算など、個人開発だからこそできた「無駄への狂信的アプローチ」がこの滑らかな手触りを支えている。生存時間によって結果画面での称号が「プリン見習い」から「人間水準器」まで10段階に変わるため、ぜひ自分の絶対水平感覚の限界に挑んでみてほしい。Google Playストアにて公開中なので、あなたの端末のセンサーを極限までプルプルさせてみてはどうだろうか。