🧚

【虫注意】かわいい妖精と暮らす癒しのデスクトップライフとその最適化

に公開

macOS 26のみでUI崩れが発生する挙動に頭を悩ませているラビーのK@zuki. です。

さっそくですが、デスクトップに可愛い妖精がいたら癒されませんか?
壁に沿って歩き、近づくと逃げていく。そんな愛らしい妖精をmacOSのデスクトップに召喚するアプリ、GOKIsを開発しました。

https://apps.apple.com/jp/app/gokis/id6756579650?l=en-US&mt=12

この記事は、GOKIs の開発背景や技術的な工夫について紹介します。
私はMetalの専門家ではないですがちょっとしたアプリの最適化で嗜んでいる程度です。
他の方がMetalを使ってアプリ処理最適化を行う際の参考になれば幸いです。

要約

  • macOSデスクトップ上を妖精が自由に動き回るアプリを開発
  • 論文ベースの行動モデル(壁沿い歩行・脅威回避)を実装
  • Metal + SwiftUIでGPU描画を最適化
  • 中身としては初歩的なパーティクルシステムのようなものを作っているだけ

動作環境

この記事で紹介する内容は、以下の環境で開発・動作確認を行っています。
解像度によってパフォーマンスが大きく変わるため、この記事は同一環境においての結果を示しています。

項目 スペック
マシン MacBook Air 15インチ (M4, 2025)
チップ Apple M4 (10コアCPU / 10コアGPU)
メモリ 32GB Unified Memory
OS macOS 15.6 (Sequoia)
ディスプレイ 2880 x 1864 Retina

きっかけ

先週末、【2025年ラスト】Progateハッカソン!supported by RIZAP テクノロジーズにメンターとして参加していました。

https://progate.connpass.com/event/374994/

ハッカソン形式のイベントで、各チームの開発を見て回っていたところ、あるチームがCockroach DBを採用しているのを発見し、社会人からも中々出ない渋いチョイスに感心していました。

https://topaz.dev/projects/2ea479052aa5c39fc6b0

1日目の夕方頃に、せっかくなので「メンターたちも何か作ろう」という話になり、その日の夜に自分も何か作ることにしました。
作っていたのは3つあり、ハッカソンの懇親会でチラッと見た方もいるかと思います。

アプリ名 概要
コミットさせろ Next.js + AI SDK + VRMを使ったAI尋問ゲーム
GokiRun Swift + Metalを使ったデスクトップ妖精シミュレーション(今回の元ネタ)
パン屋革命 Unityを使ったフランス革命を舞台に貴族に目をつけられずに生き残るシミュレーションゲーム

このときに作成した、GokiRunが今回のGOKIsの原型です。

https://github.com/corrupt952/202512-hack-002

GokiRunは 単一ファイル で完結する、動作させることを優先にしていたこともあり、シンプルな実装にしていました。

【閲覧注意】初期実装の動画

ハッカソンが終わった後、「せっかくだからクソアプリアドベントカレンダーに出そう」と決意し、今回リリースにこぎつけました。

妖精の行動モデル

せっかく妖精をデスクトップで動かすなら、 ちゃんとリアルな行動をさせたい と思うので、いくつかの論文を参考にして行動モデルを設計しました。
調べてみると、この生物の行動は複数の研究で科学的に解明されていることがわかりました。

逃避角度の研究

Domenici et al. (2008) の研究によると、この生物は完全にランダムな方向に逃げるのではなく、特定の角度を好んで選ぶことがわかっています。

最尤法による分析で、以下の5つのピーク角度が同定されました。

角度 説明
約97° 脅威に対してほぼ真横に逃げる
約124° やや後方斜めに逃げる
約150° さらに後方に逃げる
約177° 脅威からほぼ真っ直ぐ逃げる
約204° 後方からの脅威時のみ出現

興味深いことに、脅威の方向によって使える角度が変わります。
前方からの脅威には4方向から選び、後方からの脅威には別の3方向から選ぶ。
これは捕食者の予測を困難にする「構造化された予測不可能性」と考えられています。

Kawabata et al. (2023) では、魚類での実験を通じて、この角度選択のメカニズムを幾何学的モデルで説明しています。
方向転換にかかる時間コストと、捕食者の攻撃距離の制限を考慮することで、なぜ複数の好まれる角度が存在するのかが数学的に導出されました。
このモデルは昆虫を含む多様な種に適用可能とされており、Domenici自身も共著者として参加しています。

これらを踏まえ、GOKIsでは脅威の方向に応じて逃避角度を選択するように実装しています。

// Metal Shaderでの実装
constant float escapeAngles[5] = {
    1.658,   // 95°  (論文値: 96.9°)
    2.182,   // 125° (論文値: 123.6°)
    2.618,   // 150° (論文値: 150.3°)
    3.142,   // 180° (論文値: 177.0°)
    3.578    // 205° (論文値: 204°)
};

// 脅威方向に基づいて使用する角度を選択
if (absBeta < HALF_PI) {
    // 前方脅威: 4つの角度を使用
} else if (absBeta < TWO_THIRDS_PI) {
    // 側方脅威: 3つの角度を使用
} else {
    // 後方脅威: 150°, 180°, 205°を使用
}

「向かってくる」応答

同じ研究チームの Domenici et al. (2009) では、約5-18%の確率で脅威に向かって逃げる行動が観察されました。
これは「誤発火」ではなく、進化的に獲得された戦略の一部と考えられています。
皆さんも追いかけていると、突然こちらに向かってくることがあるのを経験したことがあるかもしれません。

GOKIsでもこれを再現しており、約10%の確率で向かってくる恐怖感を演出しています。

// 90%は逃げる、10%は向かう
if (random_float(&seed) < 0.9) {
    // Away: 逃避角度に基づいて逃げる
} else {
    // Towards: 70%は小ターン、30%は突進
}

二段階脅威応答

Camhi & Nolen (1981) の古典的研究では、この生物の脅威への応答が二段階であることが報告されています。

刺激強度 風速 反応 潜時
弱い刺激 3 mm/s 一時停止して様子を見る -
強い刺激 12 mm/s以上 即座に逃げる 11-18ms

わずか0.01秒で逃走を開始できるのは、尾部の糸状毛(風センサー)と巨大介在ニューロンによる高速な神経回路のおかげです。

GOKIsでは、脅威との距離に応じて二段階の応答を実装しています。

if (alertLevel > escapeThreshold) {
    state = STATE_ESCAPE;  // 強い刺激: 即座に逃げる
} else if (alertLevel > alertThreshold) {
    state = STATE_ALERT;   // 弱い刺激: 一時停止して評価
}

壁沿い歩行(Thigmotaxis)

この生物は壁に沿って移動することを好みます。
これは thigmotaxis(接触走性) と呼ばれ、触覚で壁を感知しながら安全を確保する行動です。

Laurent Salazar et al. (2018) の研究では、個体によって壁への親和性が異なることも示されています。
壁好きな個体は反応が遅く(約27秒)、探検家タイプは反応が速い(約7秒)傾向があります。

GOKIsでは各個体に thigmotaxis パラメータを持たせ、壁への親和性に個体差を持たせています。

妖精AI

科学的な逃避行動を実装した後、次の課題は「日常の行動」でした。
妖精は逃げてばかりではありません。壁に沿って歩いたり、休んだり、探索したりします。

Utility AI(効用ベースAI)

ゲームAIの分野では、Utility AIという手法がよく使われます。
The SimsやRimWorldなどで採用されている手法で、各行動に「効用スコア」を計算し、最も高いスコアの行動を選択します。

GOKIsでは、4つの日常行動に対して線形効用関数でスコアを計算しています。

行動 説明
REST 休息する(疲労時に優先)
WALL_FOLLOW 壁に沿って歩く
EXPLORE 開けた場所を探索する
SEEK_WALL 壁を探して移動する

スコア計算

各行動のスコアは、内部状態と個性パラメータの線形結合で計算されます。
直感的には以下のような意味です。

  • REST ... 疲労度が高いほど、活動性が低いほど休みたくなる
  • WALL_FOLLOW ... 接触欲求・壁親和性が高く、疲れていないほど壁沿いを好む
  • EXPLORE ... 接触欲求・壁親和性が低く、活動性が高いほど探索したくなる
  • SEEK_WALL ... 接触欲求・壁親和性が高いほど壁を探したくなる

数式で表すと以下のようになります。

U_{REST} = 0.50f + 0.15(1-\alpha) + B_{wall} + B_{corner}
U_{WALL} = 0.25c + 0.20\tau + 0.20(1-f) + 0.10\alpha + B_{wall}
U_{EXPLORE} = 0.30(1-c) + 0.25(1-\tau) + 0.25\alpha + 0.15(1-f) + B_{open}
U_{SEEK} = 0.40c + 0.30\tau + 0.10(1-f) + B_{open}
変数 意味
f 疲労度 (fatigue)
c 接触欲求 (contactNeed)
\tau 壁親和性 (thigmotaxis)
\alpha 活動性 (activity)
B 状況ボーナス

実際のMetal Shaderでの実装例(REST)は、以下のようになります。

float restScore =
    g.fatigue * 0.50 +              // 疲労度(主要因子)
    (1.0 - g.activity) * 0.15 +     // 個性: 活動性
    (nearWall ? 0.20 : 0.05) +      // 壁ボーナス
    (inCorner ? 0.10 : 0.0);        // 角ボーナス

最も高いスコアの行動が選択されるようになっており、妖精は疲れていれば休み、壁が近ければ壁沿いに歩き、開けた場所にいれば探索に出かけます。

個性パラメータ

各個体は生成時に3つの個性パラメータを持たせています。

パラメータ 分布 影響
thigmotaxis N(0.5, 0.15²) 壁への親和性(0: 探検家、1: 壁好き)
boldness N(0.5, 0.20²) 大胆さ(0: 神経質、1: 大胆)
activity N(0.5, 0.15²) 活動性(0: 怠惰、1: 活発)

これにより、同じ状況でも個体ごとに異なる行動を取ります。
壁が好きな個体は壁に沿って歩き続け、大胆な個体は脅威に近づいても逃げにくい。

履歴ベースの調整

単純なスコア計算だけだと、同じ行動を繰り返すか、頻繁に切り替わりすぎます。
これを防ぐため、履歴に基づく調整を加えて微調整しています。

調整 効果
モメンタム 現在の行動を続けやすくする (+0.10)
クールダウン 直前の行動を避けやすくする (-0.15)
壁飽和 壁に15秒以上いると探索したくなる
開放不安 開けた場所に8秒以上いると壁を探したくなる

これにより、自然な行動の流れが生まれます。
壁に沿って歩き続けた後は探索に出かけ、しばらく開けた場所にいると不安になって壁を探す...といった具合です。

まだまだ改善の余地はありますが、これらがなかった時と比べると、かなり自然な行動になっています。

全体のアーキテクチャ

GOKIsのAIは2層構造で、合計7つの状態を持ちます。

Layer 1: Survival Check(生存チェック)

脅威検出時に日常行動を中断し、ALERT or ESCAPEに強制遷移します。
こうすることで、妖精が人間に直面した時のような一時停止、観察、逃避行動を実現しています。

Layer 2: Utility Scoring(効用計算)

脅威がない平常時は、Utility Scoringに基づいて日常行動を選択します。

この構造は、実際の妖精の行動パターンを反映しています。
脅威があれば即座に対応し、安全なときは欲求に基づいて行動する。
シンプルながらも、自然な振る舞いを実現しています。

最適化という名の沼

さて、ここからが本題です。
最終的には、最適化を重ねた結果、100,000匹でも60fpsで安定動作するようになりました。
ここからは、その最適化の旅を振り返ります。

初期実装の限界

GokiRunでは、各個体にNSWindowを1つ割り当てるという豪快な実装でした。

// 初期実装: 匹数分のウィンドウを生成
for i in 0..<gokiCount {
    let window = GokiWindow()  // NSWindow継承
    window.orderFront(nil)
    gokiWindows.append(window)
}

ハッカソンで動くものを作るには十分でしたが、これだと300〜500匹が限界です。
macOSのウィンドウシステムは大量のウィンドウを想定していません。

「どうせならもっと増やしたい」 —— その欲望が沼への第一歩でした。

アーキテクチャの刷新

最初に取り組んだのは、根本的なアーキテクチャの変更です。

NSWindow × N → MTKView × 1

ウィンドウを1つの透明オーバーレイに統合し、MTKView(Metal Kit View)で描画する構成に変更しました。

MTKViewの初期化では、透明背景と60fpsの自動レンダリングを設定しています。

private func configure() {
    wantsLayer = true
    layer?.isOpaque = false
    clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)
    colorPixelFormat = .bgra8Unorm
    preferredFramesPerSecond = 60
    enableSetNeedsDisplay = false  // 自動描画ループを有効化
}

CoreGraphics → Metal描画

CPU描画(CoreGraphics)をGPU描画(Metal)に完全移行しました。
インスタンス描画により、10万匹でも1回のドローコールで描画できます。

// 1回のドローコールで全GOKIを描画
encoder.drawPrimitives(
    type: .triangle,
    vertexStart: 0,
    vertexCount: 6,          // 1匹あたり6頂点(2三角形)
    instanceCount: gokiCount  // 10万でも1ドローコール
)

コマンドバッファの統合

当初はシミュレーション(Compute)と描画(Render)で別々のコマンドバッファを使っていましたが、単一コマンドバッファに統合することでシステムコールを50%削減しました。

func draw(in view: MTKView) {
    guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }

    // Phase 1: Compute(シミュレーション)
    let gokiStateBuffer = engine.encodeSimulation(into: commandBuffer)

    // Phase 2: Render(描画)
    // Metal内部で自動的にCompute→Renderの順序を保証
    guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return }
    encoder.setVertexBuffer(gokiStateBuffer, offset: 0, index: 1)
    encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: gokiCount)
    encoder.endEncoding()

    // 1回のcommitで完結
    commandBuffer.present(drawable)
    commandBuffer.commit()
}

ここまでの最適化で、1,000〜10,000匹が安定動作するようになりました。

メモリとの戦い

次のボトルネックはメモリ帯域でした。

Unified Memoryの活用

Apple SiliconのUnified Memoryを最大限活用し、CPU-GPU間のメモリコピーを完全に排除しました。

// Unified Memoryでバッファを確保
let buffer = device.makeBuffer(
    length: MemoryLayout<GokiState>.stride * count,
    options: .storageModeShared  // CPU/GPU共有
)

.storageModeSharedを指定することで、CPUとGPUが同じメモリアドレスにアクセスできます。
シェーダーにはメモリの参照だけを渡し、コピーのオーバーヘッドをゼロにしました。

Half精度化(16bit浮動小数点)

メモリ帯域をさらに削減するため、内部状態をFloat(32bit)からhalf(16bit)に変更しました。

// SharedTypes.h - Swift/Metal共通の構造体定義
typedef struct {
    // Position and movement - floatを維持(精度が必要)
    simd_float2 position;
    simd_float2 velocity;
    float targetAngle;
    float currentAngle;

    // Internal state - halfに変更(0-1の正規化値)
    HALF_TYPE fatigue;       // 疲労度
    HALF_TYPE contactNeed;   // 接触欲求
    HALF_TYPE alertLevel;    // 警戒レベル
    HALF_TYPE thigmotaxis;   // 壁親和性
    HALF_TYPE boldness;      // 大胆さ
    HALF_TYPE activity;      // 活動性
    // ...
} GokiState;  // 80バイト → 64バイト(20%削減)

位置や角度には精度が必要なのでfloatを維持し、0〜1の正規化された内部状態のみhalfに変更しました。
10万匹で約1.5MBのメモリ削減(7.6MB → 6.1MB、20%削減)になります。
Triple bufferingやGPU側のバッファも含めると、実測ではより大きなメモリ消費量の改善が確認できました。

Metal Shader側でもhalf精度の定数を定義し、GPU演算も最適化しています。

// GPU最適化用のHalf精度定数
constant half FATIGUE_INCREASE_RATE_H = 0.003h;
constant half FATIGUE_DECREASE_RATE_H = 0.005h;

// 内部状態の更新
half hdt60 = half(dt60);
g.fatigue += FATIGUE_INCREASE_RATE_H * hdt60;
g.fatigue = clamp(g.fatigue, half(0), half(1));

Intel Mac対応の落とし穴

ここで罠にハマりました。Intel MacではFloat16が使えないのです。

Apple SiliconではFloat16がネイティブサポートされていますが、x86_64では利用できません。
App Store審査でIntel Mac対応が必要だったため、ソフトウェアエミュレーションを実装しました。

#if arch(arm64)
    // Apple Silicon: ネイティブFloat16
    public typealias HalfFloat = Float16
#else
    // Intel Mac: IEEE 754 binary16のビットパターンで保持
    public typealias HalfFloat = UInt16

    public func floatToHalf(_ value: Float) -> UInt16 {
        let bits = value.bitPattern
        let sign = (bits >> 16) & 0x8000
        let exponent = Int((bits >> 23) & 0xFF) - 127 + 15
        let mantissa = bits & 0x007F_FFFF
        // サブノーマル、無限大、NaNも正しく処理
        // ...
        return UInt16(sign | UInt32(exponent << 10) | (mantissa >> 13))
    }
#endif

Metal Shader側ではhalf型として読み込むため、ビットパターンさえ正しければ問題なく動作します。

Intel Macは持ってないので本当に動くかは不明ですが、多分大丈夫なはずです。

この時点で、100,000匹が60fpsで安定動作するようになりました。

マイクロ最適化の世界

ここからは、30万や50万匹で60fpsを目指して、マイクロ最適化の世界に突入しました。
最終的には、300,000匹で50fps前後、500,000匹で30fps前後まで到達しました。

角度計算のO(1)化

角度を[-π, π]の範囲に正規化する処理は頻繁に呼ばれます。
最初はwhile loopで実装していましたが、精度を犠牲にしても良いので、単一の算術演算に置き換えました。

// Before: O(n) - GPUのSIMD divergenceを引き起こす
while (angle > PI) angle -= TWO_PI;
while (angle < -PI) angle += TWO_PI;

// After: O(1) - 単一計算で完了
float normalizeAngle(float angle) {
    return angle - TWO_PI * floor((angle + PI) / TWO_PI);
}

当たり前ですがwhile loopはGPUで非常に非効率です。
スレッドごとにループ回数が異なると、最も遅いスレッドに全体が引きずられます。

sincos()の活用

sin/cosを個別に計算していた箇所を、sincos()一発呼び出しに変更しました。

// Before: 2回のSFU呼び出し
float c = cos(g.currentAngle);
float s = sin(g.currentAngle);

// After: 1回のSFU呼び出し
float c, s;
s = sincos(g.currentAngle, c);

GPUのSpecial Function Unit(SFU)を効率的に使うことで、約30%の高速化が見込めます。

画面サイズのキャッシング

毎フレームNSScreen.main?.visibleFrameを呼んでいたのを、通知ベースのキャッシングに変更しました。

private var cachedVisibleFrame: CGRect = .zero

init?() {
    cachedVisibleFrame = NSScreen.main?.visibleFrame ?? .zero

    // 画面構成が変わったときだけ更新
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(screenDidChange),
        name: NSApplication.didChangeScreenParametersNotification,
        object: nil
    )
}

@objc private func screenDidChange(_ notification: Notification) {
    cachedVisibleFrame = NSScreen.main?.visibleFrame ?? cachedVisibleFrame
}

60fpsなら毎秒60回、120fpsなら毎秒120回のシステムコールを削減できます。(実際にはそう単純ではないが)

シェーダー定数の事前定義

頂点座標やテクスチャ座標をconstant配列として事前定義し、GPUのレジスタに保持させました。

// constant修飾子でGPUレジスタに保持
constant float2 quadPositions[6] = {
    float2(-17, -12), float2(17, -12), float2(-17, 12),
    float2(17, -12), float2(17, 12), float2(-17, 12)
};

constant half2 quadTexCoords[6] = {
    half2(0, 1), half2(1, 1), half2(0, 0),
    half2(1, 1), half2(1, 0), half2(0, 0)
};

あまり意味がないように思えますが、50万匹で23fpsから25fpsに向上しました。

可変フレームレート対応

ProMotionディスプレイ(120Hz)でも、通常の60Hzでも、フレームドロップ時でも同じ挙動を保証するため、deltaTimeベースの計算に統一しました。

// Swift側: deltaTimeの計算
let currentTime = CACurrentMediaTime()
let measuredDelta = currentTime - lastFrameTime
lastFrameTime = currentTime

// 上限120fps、下限10fpsでクランプ
let deltaTime = Float(min(max(measuredDelta, 1.0/120.0), 1.0/10.0))
// Metal側: 60fps基準のスケーリング係数
float dt60 = params.deltaTime * 60.0;  // 60fpsで1.0、30fpsで2.0

// 速度更新
g.velocity += acceleration * dt60;

// 確率もスケーリング(フレームレートが高いほど発生頻度を下げる)
if (random_float(&seed) < TURN_PROBABILITY * dt60) {
    // 方向転換
}

// 減衰はpow()で正確に計算
g.velocity *= pow(FRICTION, dt60);  // 0.94^dt60

特に減衰計算は重要です。単純な乗算だとフレームレートによって減衰速度が変わってしまいます。

// ❌ フレームレート依存
g.velocity *= 0.94;  // 60fpsと30fpsで異なる減衰

// ✅ フレームレート非依存
g.velocity *= pow(0.94, dt60);  // どのフレームレートでも同じ減衰

最終結果

最終的な結果をまとめます。

項目 GokiRun(初期) GOKIs(最終)
最大匹数(60fps) 100〜300 100,000
描画方式 NSWindow × N MTKView × 1
メモリコピー 毎フレーム ゼロコピー
GokiStateサイズ - 64バイト
浮動小数点精度 Float Float + Half
フレームレート対応 60fps固定 10〜120fps

実測パフォーマンス(M4 MacBook Air)としては、以下の通りです。

匹数 fps 状態
10,000 60 スムーズ
100,000 60 安定
200,000 58~60 プレイ可能
300,000 50 実用限界
500,000 30 ギリギリ

10万匹の妖精がデスクトップを埋め尽くせるようになりました 🎉

【閲覧注意】 100,000匹が動いている動画

妖精の正体

ここまで論文ベースの行動モデルやUtility AI、Metalでの最適化について語ってきましたが、ここで種明かしをします。

GOKIsの正体は、ただのパーティクルシステムのようなものです。

分解してみる

GOKIsを分解すると、こうなります。

  • パーティクルシステム(基礎)
    • position, velocity
    • position += velocity * dt
    • 壁でバウンス、摩擦で減速
  • 状態機械(7状態) ← ルール追加
    • REST, WALL_FOLLOW, EXPLORE
    • SEEK_WALL, ALERT, ESCAPE
  • Utility AI ← 判断ロジック追加
    • 各行動にスコアをつける
    • 一番高いやつを選ぶ

このように組み合わせてできあがったのがGOKIsというわけです。
こうして見ると、非常にシンプルなシステムであることがわかります。

シンプルなパーティクルの例

実際にシンプルなパーティクルを作ってみました。
どちらも暗めなサンプルなので、明るい画面で見てもらえると良いかもしれません。

雨 - 重力で落ちる position += velocity * dt(+ライトにあたっている場合のみ明るく)

Rain GIF

蛍 - 自由に動いて光る、周囲を照らす

Firefly GIF

2000年代のコンピュータグラフィックスみがあって懐かしい気持ちになれますね。

そして、GOKIsは、ここに論文ベースの逃避行動とUtility AIを足しただけです。

描画シェーダーは66行

実際の描画処理を見てみましょう。

// Render.metal より抜粋

// クワッドの頂点(6頂点 = 三角形2枚)
constant float2 quadPositions[6] = {
    float2(-17, -12), float2(17, -12), float2(-17, 12),
    float2(17, -12), float2(17, 12), float2(-17, 12)
};

vertex VertexOut gokiVertexShader(
    uint vertexID [[vertex_id]],
    uint instanceID [[instance_id]],  // ← パーティクルのインデックス
    constant GokiState* gokiStates [[buffer(1)]]
) {
    GokiState goki = gokiStates[instanceID];

    // 回転して位置を足すだけ
    float2 rotated = rotate(quadPositions[vertexID], goki.currentAngle);
    float2 worldPos = goki.position + rotated;

    return worldPos / screenSize * 2.0 - 1.0;
}

1つのクワッド(四角形)を、N個インスタンシング描画しているだけです。
こうして見ても、非常にシンプルですね。

物理演算は10行

Compute Shaderの最後、物理演算の部分です。

// 速度制限
if (length(g.velocity) > maxSpeed) {
    g.velocity = normalize(g.velocity) * maxSpeed;
}

// 位置更新 ← パーティクルの本質
g.position += g.velocity * deltaTime * 60.0;

// 摩擦
g.velocity *= pow(0.94, dt60);

// 壁でバウンス
if (g.position.x < screenMin.x) {
    g.position.x = screenMin.x;
    g.velocity.x *= -0.5;
}

パーティクルシステムの教科書に出てくる、一番最初の式である position += velocity * dt がここにあります。
壁でバウンスしたり、速度制限や摩擦をかけたりしていますが、基本は同じです。

残りは全部「ルール」

685行あるCompute Shaderのうち、物理演算は最後の25行程度。
残りの660行は全部「どの方向に動くか」を決めるルールです。

  • 論文の逃避角度? → 定数配列に入れてランダム選択
  • Utility AI? → スコアを足し算して最大値を選ぶ
  • 状態遷移? → if文の分岐

アルゴリズム的に難しいことは何もしておらず、ルールをたくさん足し合わせているだけです。

一般的なパーティクルシステムにルールベースの行動ロジックを追加したものがGOKIsなのです。

応用範囲の広さ

このアーキテクチャは非常に汎用的です。

  • Boid(群れ行動)
  • 群衆シミュレーション
  • スウォームロボティクス
  • エージェントベースモデリング

など、多くの分野に応用できます。

おわりに

ここまで読んでいただきありがとうございます。
クソアプリアドベントカレンダーの記事にしては、めちゃくちゃ長かったですね。

振り返ってみると、ハッカソンの夜に「妖精が走り回るアプリ作ろう」と思い立ってから数日。
論文を読み、Utility AIを実装し、Metal最適化の沼にハマり、気づけば10万匹が60fpsで動くようになっていました。

でも、冷静に考えると、

  • 論文を何本も読んだ
  • 科学的に正確な逃避行動を実装した
  • GPUプログラミングで最適化を重ねた

その結果できたのはデスクトップに妖精を出すアプリであるGOKIsです。

世界に何の貢献もしていません。
でも、それがクソアプリの醍醐味ですよね。

GOKIsはApp Storeで無料配信中です!
あなたや友達、家族のデスクトップにも妖精を召喚してみてください。

https://apps.apple.com/jp/app/gokis/id6756579650?l=en-US&mt=12

Discussion