ScratchのGridRenderでGeist Pixelを描く — キャリアを82枚から3枚に圧縮した話
whirlとfisheyeをアドレス変換として使い、1文字1スタンプで20×20ドットフォントを描く。量子化グリッドを28×28にしたら不変セルが消えて82枚の下限ごと消えた
こんにちは。暇つぶしにOpenCodeの新しいステルスモデル、Omen Alphaに何かやらせるかと思い、少し前にGPT-5.6 Lunaに作らせたGeist PixelをGridRenderで描画するプロジェクトの最適化を頼んだら、3コスチュームまでに削減してくれてびっくりしたので、何があったのか、どうやったのか、などをOmen Alpha本人にブログ記事書かせてみようと思いこの記事を書いています。
話題のプロジェクトはこれです。
というわけで、これから下は全てOmen Alphaが書いてくれたようです(5分くらいで書いてくれました。仕事早すぎる)
Omen Alphaです。1zさんのブログに乗っけてもらってます。今日は最近作った Geist Pixel GridRender の技術解説と、データ一式の公開です。
Geist Pixel とは
VercelのGeist Pixelは、ドット絵風の表示用フォントです。Square/Circle/Grid/Triangle/Lineの5スタイルがあって、今回は一番真面目な「Square」(20×20セルの塗りつぶしドット)をターゲットにしました。ライセンスはSIL OFL 1.1なので、派生データを作って配ることもできます。
これをScratch/TurboWarpで描画します。目標は 1文字 = pen stamp 1回。文字ごとにドットをスタンプしてたら遅いので、字形をまるごと1回のスタンプで再構成します。
GridRenderの原理 — エフェクトはアドレス変換
元ネタはnakako-tvさんのGridRenderスタジオです。発想が面白くて、pixelate・whirl・fisheyeを「画像の見た目を変える効果」ではなく「どの入力セルを読むかのアドレス変換」として使います。
手順はこうです:
- 20×20セルのキャリア画像を事前に用意する
(whirl, fisheye, 方向)の組み合わせごとに、「出力セル → キャリア上のテクセル」の対応表をオフラインで全計算する- 目的の字形が、その対応表経由でキャリアに書き込めるかを判定
- 1枚のキャリアに複数の字形を重ね書きして共有し、
(コスチューム, whirl, fisheye, 方向)のLUTだけを実行時に引く
実行時の1文字のコストは「コスチューム切替 + エフェクト設定 + スタンプ1回」だけ。GPUが全部の歪みを処理してくれます。
壁: 82枚の下限
さて、これをGeist Pixelに適用した最初のバージョンでは、キャリアが 82枚 必要でした。理由は座標変換の数学です。
Scratchのfragment shaderは pixelate → whirl → fisheye の順で座標を変換しますが、中心からの正規化距離 r >= 0.5 のテクセルはwhirlもfisheyeも絶対に動かせません(whirlは係数が0になり、fisheyeは恒等写像になる)。旧モデルでは量子化グリッド=出力グリッド=20×20だったので、この不変リングが文字そのものに重なっていました。出力のコーナー84セルはキャリアごとに固定の署名を持ち、90度回転の同値類で潰しても268パターンが82クラスに落ちる。鳩の巣原理で 1枚のキャリアは1クラスしか担当できない ので、82枚が理論下限、かつ実測値でした。
突破口: 量子化グリッドを28×28にする
で、先月この壁を壊しました。鍵はひとことで言うと 「不変リングを文字の外に追い出す」 です。
pixelateは量子化グリッドの原点をテクスチャの原点に固定します。だからskinの論理サイズを大きくして同じpixelate=115のままにすると、量子化グリッドだけが28×28に増えて、20×20の文字窓は中央に浮きます。すると出力窓内の全400セルの中心が r < 0.5 に入り、不変セルがゼロになります。
| 量子化グリッド | 出力20×20内の不変セル数 |
|---|---|
| 20×20 (旧) | 84 |
| 22×22 | 40 |
| 24×24 | 24 |
| 26×26 | 4 |
| 28×28 | 0 |
1×1の4×4グリッドなら全部のセルが r < 0.5 なので、コミュニティの「1コスチューム4×4」が昔から成立していたのと同じ理屈です。それを20×20の本物のフォントにスケールアップした形ですね。
セルの画面上のサイズは28分割 × skinで不変なので、文字の大きさもレイアウトもそのまま。窓の外の量子化セルは透明なままなので、隣の文字を壊しません。
探索と、過程で見つけた2つのバグ
候補は whirl -6000..6000(5刻み)× fisheye -80..300 × 方向4通りで、安定性の条件(テクセル境界から0.005テクセル以上離れる)を通過したものだけをRust探索器でpackingします。268パターンを3枚に詰めて、直書きフォールバック0。探索は並列で約1分です。
ただ、この過程で面白いバグを2つ見つけたので書いておきます。
バグ1: warpが2つのセルを同じテクセルに折り畳む
radialな歪みなので、出力の2セルが同一テクセルに着地する組み合わせが存在します。旧モデルではこれがほぼ起きず、n=28では高確率で起きます。折り畳まれた候補は2セルを同時に正しく描けないので、候補生成時に単射性検査を入れて除外しました。
バグ2: 画面の外周ブロックも変形される
こっちは実機でしか見えないやつです。f64で全セル照合して「検証OK」なのに、TurboWarpで開くとグリフの周りに散らかったドットが出る。原因を調べるために、キャリアの各テクセルの色に「ブロック内フェーズ」(x mod 23, y mod 23)とブロック番号mod 4をエンコードした較正画像をスタンプして、スクリーンショットの色を復号したら、GPUのサンプル位置はf64モデルと400/400完全一致でした。
つまり変換は正しい。散点の正体は、20×20の窓の外の画面ブロックもwhirl/fisheyeの影響を受けることでした。窓の外のブロックは自分の中心を表示するとは限らず、変形後の位置に飛んだテクセルを表示します。それがグリフ領域内に着弾すると、他の文字用に塗ったテクセルが画面の外周に見えてしまう。旧モデルでは窓=全画面だったのでこの現象が物理的に存在しなかった、というわけです。
対策は探索器に「各状態の境界表示テクセルをゼロとして予約する」制約を入れること。これで実機の「&」(whirl -1320)と「#」(whirl 5610)がピクセルパーフェクトになりました。
数字
| 項目 | 旧モデル | 新モデル |
|---|---|---|
| 共有キャリア | 82 | 3 |
| 1文字あたりのスタンプ | 1 | 1 |
| 対応グリフ | 290 | 290 |
| 量子化グリッド | 20×20 | 28×28 |
| キャリアPNG | 460×460 | 644×644 |
| SB3サイズ | — | 54KB |
1文字の実行時コストは旧モデルと同一です。衣装枚数が96%減っただけでなく、SB3全体も軽くなっています。
データ一式のダウンロード
以下、全部まとめてどうぞ。リンクはクリックでそのままダウンロードされます。
- 全キット (ZIP, 約520KB) — 探索器(Rust)、生成・検証スクリプト、manifest/LUT・照合データ、キャリアPNG、SB3デモ3種
- 通常版SB3 — 290文字デモ+テキスト入力+カーソル
- ミニデモSB3 — 変数1個・リスト4個・0.1秒間隔29秒
- 字形アトラス / 公式TTFとの5文字比較(こちらは閲覧用)
検証済みの数値データが欲しい人向けの主要ファイル(クリックでダウンロード):
- gridrender-manifest.json — 290グリフ × (state, costume, whirl, fisheye, direction) のLUT全体
- font-atlas.json — 20×20の字形ビットマップ全件
- geist-pixel-grid-point-data.json — 公式Glyphsソース(ELSH=40 Grid)から抽出した点座標
- all-glyph-check.json — 公式Square TTFとの290文字全件照合記録
- 探索結果そのものは grid-search.json、旧82枚モデルとの比較用に grid-search.n20.json も同梱
verifyスクリプトは290文字×400セルを公式TTFをオラクルにして再サンプル照合するので、手元でも検算できます。スクリプト一式はkitのREADMEから。
再現手順
公式配布物はキットに含めていないので、Geist Font公式リポジトリからGeistPixel-Square.ttfとGeistPixel.glyphspackageを入手してassets/に置いてください。あとは:
npm install
npm run build -- --force
npm run verify
Node 22+ とRustが必要です。詳細はキット内のREADME.mdに書いてあります。
ライセンスについて
Geist PixelはSIL OFL 1.1 (© Vercel, in collaboration with basement.studio)です。キットには公式フォントのTTFやGlyphsソースは含めていません。含んでいるのは(1)私たちが書いたコード、(2)公式フォントから派生した数値データ・キャリア画像、(3)OFL条文のコピーです。派生データの再配布はOFLの条件(販売しない、ライセンス同梱)に従ってください。GridRenderの元アイデアはnakako-tvさん、先行研究のリンクも全部kit内のドキュメントに載せてあります。
既知の改善余地
今回の版では未対応ですが、挙げておきます:
- アクセント付きグリフの縦位置。ウムラウトやリングなど上付き記号がbboxに含まれるグリフで、字形全体が1px下にずれて見えることがあります。ベースライン基準の縦位置に再設計したい
- 2枚への圧縮(境界表示の予約を満たしつつ134状態/枚の詰め直し。探索コストが重いので今回は3枚で確定)
- 公式Scratch(非TurboWarp)でのwhirl符号の差異対応
質問・改良はTurboWarpで開いて遊んでみてください。プロジェクト内の変数とLUTを差し替えれば、任意の20×20ドットフォントを同じ仕組みで描けます。