BA Barış Akın~/blog

·11 分で読めます·69 閲覧

デスクトップにサンゴ礁を:Aquarium 壁紙はこうして作った

macOS と Windows のデスクトップアイコンの後ろで動くリアルタイム 3D のサンゴ礁。頂点シェーダーで泳ぐ魚、WebAssembly にコンパイルした Rust のシミュレーションコア、繁殖し成長し老いる生態系、ファイルを捨てた場所に落ちる餌、Ed25519 のライセンスチケットについて紹介します。

デスクトップにサンゴ礁を:Aquarium 壁紙はこうして作った

デスクトップの壁紙といえば、たいていは写真、ときどきループ動画です。Aquarium はそうではなく、リアルタイムで動く生きたサンゴ礁です。魚の泳ぎは毎フレーム計算され、お腹が空けば餌を探し、成長し、年を取り、繁殖し、そして死んでいきます。このアプリにはウィンドウがありません。アプリそのものがデスクトップだからです。macOS でも Windows でも、デスクトップアイコンのすぐ後ろで動作します。

この記事では、プロジェクトを層ごとに順に見ていきます。魚がどうやって泳ぐのか、Rust で書いたシミュレーションコア、2 つのプラットフォームでデスクトップアイコンの後ろにウィンドウを置く方法、そしてデスクトップからファイルを削除した場所に魚が殺到する理由です。

Aquarium:生きたサンゴ礁

Web 版はブラウザですぐに試せます:deskreef.kartex.net。マウスを動かすと、魚は驚いて素早く逃げていきます。この記事のスクリーンショットはすべて、このページで実際に動いているシーンから撮ったものです。

シーンには何があるのか

サンゴ礁には 6 種類の魚が暮らしています。エンゼルフィッシュ、ナンヨウハギ、カクレクマノミ、ディスカス、グッピー、ネオンテトラです。さらに岩の間には「住人」たちがいます。ウツボ、タツノオトシゴ、そして砂の上を滑るように進むヒラメです。カクレクマノミは何度もイソギンチャクに戻ってきて、ネオンテトラは群れで泳ぎます。

環境の大部分は実行時にプロシージャルに生成しています。砂、岩、植物、イソギンチャク、そしてシェーダーです。魚のモデルは CC0 のパックから、サンゴは Google の WebGL Aquarium サンプルとスミソニアンの 3D スキャンから、背景写真は NOAA と USFWS のサンゴ礁写真から取っています。背景は 6 種類から選べます。

紅海のテーブルサンゴの背景

ライティングには、水中で期待される効果がひととおり揃っています。砂の上で揺らめくコースティクス、水面から差し込むゴッドレイ、本物の空気レンズのように光を屈折させる泡、そして揺れる水草です。

魚はどうやって泳ぐのか

魚のモデルはスケルトンを持たない静的なメッシュです。泳ぎの動きはすべて頂点シェーダーで作り出しています。体に沿って伝わる波が尾を振り、ターンでは体の後部が頭を軸に曲がり、背びれと尻びれが波打ち、胸びれがはばたきます。それぞれ種ごとに調整できます。尾の振動数と振幅、波の数、頭の硬さ、首が曲がる位置などです。

難しいのは、ひれと胴体を見分けることです。モデルは一体成形で、どの頂点がひれに属するかを示す情報はありません。そこで小さな Python ツール(tools/finmask.py)が、各頂点からメッシュを貫くレイを飛ばして厚みを測ります。薄い面はひれとみなし、頂点あたり 4 バイトの .fin サイドカーファイルを書き出します。シェーダーはその重みを使って、ひれを胴体とは独立に動かします。

attribute vec4 aFin;  // median-fin weight, median edge, pectoral weight, pectoral edge

// swimming wave
float env = uEnv.x + (1.0 - uEnv.x) * pow(tW, uEnv.y);
float ampT = uSwim.y * env;
float ph = uSwim.x - uSwim.w * tW;
float dz = ampT * sin(ph);

波の振幅は鼻先から尾に向かってエンベロープ(env)に沿って大きくなり、位相は体に沿って伝わっていきます(uSwim.w * tW)。そのため魚は鞭のようにではなく、本物の魚のように、体の後ろ半分でほとんどの仕事をする動き方になります。シェーダーは弧長の補正もしていて、波で体が伸びてしまわないようにしています。また頭の硬い種(ディスカスなど)では波を頭の付け根で止めます。頭はひとかたまりで、尾とわずかに逆向きに振れます。一方、ベタのひれは慣性を持って布のようにたなびきます。

頭脳:Rust で書いたシミュレーションコア

魚の行動は、ページの JavaScript ではなく core/ 以下の Rust コードにあります。ステアリング、摂餌、ワームへの食いつき、成長、加齢、誕生、群れ。餌のフレークとワーム。タツノオトシゴの徘徊とヒラメの滑走。コアは wasm32-unknown-unknown 向けにコンパイルされ、140–195 KB の WebAssembly モジュールとしてページに埋め込まれます。コンパイル済みのモジュールをコミットしてあるので、npm run build は Rust ツールチェーンがなくても動きます。

Aquarium のアーキテクチャ

ページは描画だけを担当します。fish.js は魚ごとのマテリアルとシェーダーの uniform を保持し、毎フレーム、モジュールのメモリ上に重ねた Float32Array を通してコア内のその魚のレコードを読みます。間にコピーもシリアライズもありません。ワールドは境界チェック付きのブロックで構成され、ページが読む通りにワード単位で並んでいます。unsafe な行は、ホスト名を読み取るインポートの 1 行だけです。

ステアリングは、古典的なステアリングビヘイビアの考え方に基づいています。魚ごとにいくつかの力を計算し、それを足し合わせます。分離のステップは、魚同士がぶつからないようにし、群れで泳ぐ魚を引き寄せて同じ向きに揃え、そばを猛スピードで通り過ぎた魚が周りの魚を驚かせるようにします。

pub fn steer_separate(w: &mut World, i: i64) {
    let s = w.sb_of(i);
    let school = f2b(w.sg(s, s_school()));
    let pos = w.fgv(i, f_pos());
    let size = w.fg(i, f_size());
    // ...
    for j in 0..capacity() {
        // ...
        let v = vsub(pos, fpos);
        let d = vlen(v);
        let min_d = (size + fsize) * (if school && same { 0.6 } else { 1.0 });
        if d < min_d && d > 0.0001 && !feeding {
            let q = min_d / max(d, min_d * 0.2);
            steer_add(w, i, vscale(v, 1.0 / d), q * q * (if chasing { 0.15 } else { 0.6 }));
        }
        // a fish dashing past startles the ones it comes close to:
        // they shy away a little for half a second
        // ...
    }
    if n_coh > 0 {
        let c = vsub(vscale(coh, 1.0 / to_float(n_coh)), pos);
        let dc = if vlen(c) > 0.0 { vlen(c) } else { 1.0 };
        steer_add(w, i, vscale(c, 1.0 / dc), min(dc / (size * 3.0), 1.0) * 0.8);
        steer_add(w, i, vnorm(ali), 1.0);
    }
}

その上に、水槽の中にとどまる、岩を避ける、中央が混み合わないようにする、餌に向かう、ワームに食いつく、マウスから逃げる、といったステップが重なります。興味深い点を 1 つ挙げると、コアは最初 Rill で書かれ、その後 1 行ずつ Rust に移植されました。両者が同じ振る舞いをすることを確かめるため、?trace パラメーターでコアへの呼び出しをすべて記録します。tools/core-diff.mjs はその記録を 2 つの異なるビルドに対して再生し、ページが読む値のどれかが食い違った最初のフレームで停止します。砂の高さも両側で同じパーリンノイズから計算しているので、コアが考える海底の位置と描画される位置はぴったり一致します。

生態系:誕生、成長、老い

水槽は時間とともにひとりでに変化していきます。稚魚は食べることで成長し、長く空腹が続いた稚魚は死んでしまいます。成魚は年を取り、寿命が尽きると死にますが、種の個体数が初期数を下回ることはありません。出産には十分に食べた魚が必要で、繁殖力と食欲は種によって異なります。グッピーは卵胎生で、1 回の出産で 2〜4 匹の稚魚が生まれます。ナンヨウハギはほとんどの給餌に参加せず、エンゼルフィッシュは 4 回に 1 回しか加わりません。

情報パネルを開くと、種ごとの総数、成魚・老魚・稚魚の数、出産確率、そして個々の魚がどれだけ満腹かをリアルタイムで確認できます。

個体数パネル:種、誕生、各魚の満腹度

ライフサイクルの中心は life_tick 関数です。稚魚の成長は満腹度に結びついていて、魚は食べた分しか成長できません。死も一度に訪れるわけではなく、死にゆく魚は徐々に動きが遅くなっていきます。

pub fn life_tick(w: &mut World, i: i64, dt: f64) {
    let s = w.sb_of(i);
    let life = w.wf(w_life());
    let age = w.fg(i, f_age());
    if age < 1.0 {
        let fed = min(1.0, w.fg(i, f_fed()) + dt * life * 0.00025);
        w.fput(i, f_fed(), fed);
        if fed > age {
            let age2 = min(fed, age + dt * life / w.sg(s, s_grow_time()));
            w.fput(i, f_age(), age2);
            // ...
        }
    }
    // ...
}

メニューからは、生態系を半分の速度に落としたり 10 倍速で動かしたり、特定の種に手動で出産させたり、魚を 1 匹死なせたりできます。画質(低/中/高)、フレームレート(24/30/60)、背景、サウンド、FPS カウンターもここで設定します。

設定メニュー

デスクトップから餌をあげる

私のいちばんのお気に入り機能です。デスクトップ上のファイルをゴミ箱に入れる(ゴミ箱へドラッグするか ⌘⌫ を押す)と、そのアイコンがあった場所に魚が殺到します。その地点から餌のフレークが沈んでいき、魚たちが集まってしばらく食べ、やがて散っていきます。

餌の時間:餌に群がるカクレクマノミ

追加の権限は必要ありません。macOS アプリは DispatchSource で ~/Desktop フォルダーを監視しています。項目が消えると、最後にクリックされた位置をアイコンの位置とみなし、ウィンドウリストでその地点が本当にデスクトップで、ほかのウィンドウに覆われていないことを確認してから、Web ビューに「ここに餌を落として」と伝えます。Windows 版も独自の DesktopWatcher で同じことをしています。

デスクトップの後ろにウィンドウを置く

アプリはスクリーンごとに Web ビューを 1 つ描画し、それをデスクトップアイコンの後ろに配置します。そのやり方は 2 つのプラットフォームで大きく異なります。

macOS では比較的すっきりしています。デスクトップウィンドウのレベルに置かれ、すべての Space に表示され、マウスイベントを無視する、ボーダーレスの NSWindow です。

window = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false, screen: screen)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.isOpaque = true
window.hasShadow = false
window.ignoresMouseEvents = true

アプリは、画面がスリープまたはロックされたとき、そしてウィンドウが完全に覆われたとき(occlusionState)に自動で一時停止します。誰にも見えない水槽に CPU を使うことはありません。メニューバーの 🐠 アイコンからは、一時停止、画質とフレームレートの選択、ログイン時の起動、リロード、終了ができます。

Windows には公式の方法がありません。そこで Wallpaper Engine のようなアプリで使われている古典的なトリックを使います。Progman ウィンドウに非公開の 0x052C メッセージを送って WorkerW を生成させ、アイコンビュー(SHELLDLL_DefView)の後ろにある WorkerW を見つけて、ウィンドウをその子にします。その WorkerW がどこにできるかは Windows のバージョンによって異なるため、コードは既知の 2 つのレイアウトの両方に対応しています。

public static IntPtr FindWorkerW()
{
    IntPtr progman = FindWindow("Progman", null);
    if (progman != IntPtr.Zero)
    {
        SendMessageTimeout(progman, 0x052C, IntPtr.Zero, IntPtr.Zero, 0, 1000, out _);
    }

    // Windows 11 (observed on build 26200): Progman hosts the spawned WorkerW as its own
    // child, below SHELLDLL_DefView in z-order.
    IntPtr child = FindWindowEx(progman, IntPtr.Zero, "WorkerW", null);
    if (child != IntPtr.Zero) return child;

    // Windows 10 and earlier: the WorkerW is a top-level window sitting just behind the one
    // that hosts the icon view.
    // ...
}

Windows ホストは .NET 8 と WebView2 で書かれていて、同じ web/ バンドルを再利用しています。.NET ランタイムは exe に同梱されており、インストーラーは管理者権限を要求しません。WebView2 Runtime は Windows 11 と最新の Windows 10 に最初から入っています。

Web 版とライセンスチケット

Web 版には特有の問題があります。ページと WebAssembly モジュールは公開されているので、コピーしてほかの場所でホストされてしまう可能性があります。これを防ぐため、コアは自分自身にライセンスを与えません。ページは 5 分ごとにサーバーからチケットを取得し、コアに渡します。チケットは host|issued|expires|Ed25519 signature という形をしています。コアはそれを自分で読み取ったホスト名と照らし合わせて検証します。署名は有効か、チケットは前回のものより新しいか、有効期限は切れていないか、そして実行時間 25 分ごとに少なくとも 1 枚は届いているか、です。

コアが持っているのは公開鍵だけなので、チケットを偽造することはできません。エンドポイントは CORS ヘッダーを送らず、クライアントごとにレート制限がかかっています。ほかの場所に置かれたサイトのコピーはチケットを受け取れず、150 秒後に終わりを迎えます。すべての魚が病気になり、沈んでいくのです。署名鍵はソースには含まれておらず、手元のマシンとサーバーにだけ存在し、デプロイのたびに新しく作られます。一方、デスクトップアプリ内のコアは、それぞれのオリジンにオフラインでライセンスを与えます。

ファイアーコーラルの背景

数字で見るプロジェクト

Rust(シミュレーションコア)約 5,300 行
JavaScript(描画、シェーダー、メニュー)約 3,400 行
C#(Windows ホスト)約 1,200 行
Swift(macOS ホスト)約 570 行
Python(ひれマスクとモデル用ツール)約 500 行
コミット数173
WebAssembly コア140–195 KB
種魚 6 種 + ウツボ、タツノオトシゴ、ヒラメ

macOS 版は、Intel と Apple Silicon の両方で動く単一のユニバーサルバイナリです。Windows 向けにはインストーラーとポータブル版の zip の両方を用意しています。どちらもコード署名されていないため、初回起動時には macOS では右クリックして「開く」を選び、Windows では SmartScreen で「実行」を選んでください。

まとめ

Aquarium からはいくつかのことを学びました。壁紙は常にオンで、誰も見ていないときでも動き続けるプログラムです。だからパフォーマンスと一時停止のルールは、見た目と同じくらい重要です。描画を Web 技術で、頭脳を Rust で書いたことで、両者の長所を兼ね備えることができました。同じシーンが 2 つの OS とブラウザで動き、シミュレーションはどこでもまったく同じように振る舞います。そして、ファイルをゴミ箱に入れたばかりの場所へ魚が駆け寄ってくるのは、思っていたよりずっと楽しいインタラクションになりました。

このソフトウェアは Kartex Teknoloji ve Bilişim A.Ş. に帰属します。サードパーティのモデル、テクスチャ、写真、サウンドはそれぞれのライセンスに従い、NOTICES.txt に記載しています。Web 版は deskreef.kartex.net で試せます。ご質問は X または LinkedIn でお気軽にどうぞ。

BA
Barış Akın

25年以上の経験を持つソフトウェアソリューションアーキテクト。FinTechと分散システム。OxiDB、Rill、Cobraの開発者。