はじめに
Pyxel 作者です。皆さん、いつもレトロゲームエンジン Pyxel をご利用いただきありがとうございます。
ゴールデンウィーク、いかがお過ごしでしょうか。せっかくのお休みなので、今日はちょっと Pyxel の裏側っぽい話を書いてみたいと思います。
(そもそも Pyxel って何?という方はこちらの記事をご参照ください。)
Pyxel を使ってくださっている方や、Pyxel のことを耳にした方から、よくこんな質問をいただきます。
「Pyxel って Python で書かれてるんですよね?」
「いろいろな環境で動かすの、大変じゃないですか?」
「Pyxel はなぜ Python を Web ブラウザで動かせるのですか?」
「レトロな描画はどうやって実現しているんですか?」
「どうやってファミコンサウンドを再現しているんですか?」
実は、これらの質問、一言で答えるのがなかなか難しいです。
Pyxel は『気軽に楽しくゲームプログラミング』をコンセプトにしたエンジンです。表側はシンプルで分かりやすく作っているのですが、その裏では、レトロな見た目や音を実現するために、こっそりさまざまなことをやっており、その辺りに関わる質問だからです。
そこで、本記事では、よく聞かれる疑問に答えながら、Pyxel の裏側で動いている仕組みを紹介していこうと思います。読みながら「へえー、そんな風になっているんだ」と楽しんでいただければ幸いです。
今回お話しするトピックは、こちらの 5 つです。
- Pyxel は何で書かれているのか
- いろいろな環境で動かしている方法
- Pyxel が Web ブラウザで動く仕組み
- レトロな絵を描いている仕組み
- ファミコン風サウンドを鳴らしている仕組み
それでは、順に見ていきましょう。
Pyxel は何で書かれているのか
「Pyxel って Python で書かれてるんですよね?」とよく聞かれるのですが、答えは「一見 Python だけど、中身は 99% Rust」になります。
皆さんが import pyxel して触れる API は確かに Python ですが、エンジン本体は Rust で書かれています。
Rust 側の実装は、2 つのモジュール(Rust でいう crate)に分かれています。
-
pyxel-core: Rust で書かれたエンジン本体(描画・サウンド・入力など) -
pyxel-binding: Python からpyxel-coreを呼べるようにする薄いラッパー(接続層)
Python と Rust をつなぐ道具
Python には、もともと他の言語で書かれたコードを呼び出せる仕組みがあります。PyO3 はそれを Rust 向けに使いやすくしてくれるライブラリで、もう一つ maturin は Rust のコードを「wheel」という Python パッケージ形式にまとめてくれるツールです。この 2 つのおかげで、Rust 製のエンジンが pip install pyxel で簡単にインストールできるようになっています。
ちなみに、ソウルにお住まいらしい maturin の作者の方には、Pyxel のビルドまわりで何度も助けていただいています。年の瀬に発覚したバグを、お正月返上で一緒に解決していただいたこともありました。本当にありがたい話です。
Rust を採用した理由
Pyxel の中では、ピクセル単位の描画や 1 サンプル単位の音声計算が高頻度(30〜60fps)で繰り返されています。こうした処理を安定的に実現するのに、Rust はちょうどいい言語でした。
- 堅牢で、突然クラッシュする系のバグを抑え込みやすい
- C や C++ と並ぶ実行速度
- 必要なライブラリ(crate)を
Cargo.tomlに書くだけで導入できる - いろいろな OS や CPU 向けにビルドしやすい
ただし、Rust は堅牢なぶん、ゲームのように「思いついたことをサッと書きたい」コードには窮屈なところがあります。そこで Pyxel では、エンジン本体は Rust、ゲームを書く側は Python、という役割分担にしています。
ちなみに、Pyxel は最初は C++ で書かれていました。途中で、メモリ管理の堅牢さや、マルチプラットフォーム対応のしやすさ、エコシステムの使い勝手などを求めて、Rust に全面的に書き直した経緯があります。リライトはなかなか骨の折れる作業でしたが、おかげでいまの安定した動作と幅広い対応が実現できています。
なお、Rust 版の crate を使って、Pyxel アプリを Rust だけで書くこともできますが、いつもの Pyxel のノリで書こうとすると Rust の作法でがんじがらめになるので、ふだんは Python から使うのがおすすめです。
最後の「いろいろな OS や CPU 向けにビルドしやすい」は、Pyxel がたくさんの環境で動く理由に直結します。それは次の章で見ていきましょう。
いろいろな環境で動かしている方法
「あんなにいろいろな環境で Pyxel が動くのは、どうやっているんですか?」ともよく聞かれます。Pyxel のマルチプラットフォーム対応は、設計上の役割分担の工夫と、各種ライブラリの組み合わせで、できるだけ効率よく実現しています。
Pyxel は現在、こんな環境で動きます。
- Windows / macOS / Linux のデスクトップ
- Web ブラウザ(iOS の Safari、Android の Chrome 含む)
- Chrome OS
- Raspberry Pi や中華ゲーム機などの SBC(小型コンピュータ)
Rust の cross compile を活用
マルチプラットフォーム対応を効率よく実現できているのは、エンジン本体を Rust で書いたことが大きいです。Rust には cross compile(同じソースから別の OS や CPU 向けにビルドする仕組み)が整っており、cargo build --target ... のように一行指定するだけで、各ターゲットに合わせた成果物を作ってくれます。
ビルドした結果は、前の章で紹介した maturin が各 OS 向けに wheel パッケージとしてまとめてくれます。Windows 用、macOS(Intel / Apple Silicon)用、Linux 用、といったバリエーションがいっせいに作られ、それらを詰め合わせたパッケージとして配布されます。そして皆さんが pip install pyxel すると、利用環境に合った wheel が自動的に選ばれます。
OS の違いを吸収する仕組み
OS によって違う部分——ウィンドウの作り方、キーボードやマウスの取り方、音の出力など——は、Pyxel として共通の操作ルール(プラットフォーム抽象化レイヤ)を被せたうえで、実際の機能は SDL2 という古くから使われている共通ライブラリで実現しています。また、画素単位の細かい処理が大量に走る部分では、デスクトップ向けに OpenGL、ブラウザや小型コンピュータ向けに OpenGL ES も使っています。これらも多くの環境で動く仕組みなので、Pyxel 側からは「OS が違っても同じコードで動く」状態が作れています。
とはいえ、プラットフォームによって細かいクセが違っているのもよくあることで、皆さんからの不具合報告をいただきながら、都度対応を行なっています。私がすべての動作環境を持っているわけでもないため、ここは Pyxel の開発で最も苦労する箇所の一つでもあります。
対応プラットフォームの中でも、Web ブラウザ対応は特に難易度の高い部分になります。それは次の章で詳しく見ていきましょう。
Pyxel が Web ブラウザで動く仕組み
「なんで Python がブラウザで動くんですか?」というのも、よくいただく質問です。
ブラウザは Python を実行する仕組みを持っていないので、確かに不思議に思えますよね。ここで活躍しているのが Pyodide というライブラリです。Pyodide は、Python の処理系(CPython)を WebAssembly(略して WASM)にコンパイルしたもので、ブラウザの中で Python を動かせるようにしてくれます。
「それを使えば Python が動かせるんですね」と納得はしていただけるのですが、実は Pyxel の場合、Pyodide が動けば完成というわけにはいかず、その上でいくつか工夫しています。
Pyxel コアも WASM 化してセットで送り込む
最初にお伝えした通り、Pyxel のエンジン本体は Rust で書かれています。つまり、Pyxel を Web で動かすには「Python を動かす仕組み」だけでなく、「Rust を動かす仕組み」も必要になります。さらに、Rust のコアエンジンが依存している SDL2 もブラウザ上で動かさなければなりません。
そこで Pyxel では、Rust 製のエンジン本体(pyxel-core)も WASM にコンパイルしてブラウザに送り込んでいます。ブラウザの中では、Pyodide が動かす Python が、この WASM 化された Pyxel コアを呼び出して動きます。
整理するとこんな感じです。
- ブラウザ: HTML / JS で Pyxel ページを開く
- Pyodide: ブラウザの中で Python を動かす
-
pyxel-binding(Rust → WASM): Python からpyxel-coreを呼べるようにする薄いラッパー(接続層) -
pyxel-core(Rust → WASM): エンジン本体(内部で WASM 版 SDL2 を利用)
前に説明した「Python ↔ Rust」の二層構造が、Web の世界では「Pyodide ↔ WASM」というかたちにそのまま乗り換わっています。
Pyodide チームとの協力
実はここに至るまでには長い道のりがあり、Pyodide チームの皆さんと共に1年くらい試行錯誤をしました。大変だったのは、Pyxel が依存している SDL2 をブラウザ上で動かし、それを Pyodide が wheel として読み込めるようにする部分の確立です。Pyodide 側にも改変や検証が必要で、チームの皆さんに快く対応していただけたおかげで、いまの Web 実行環境が成り立っています。
HTML を拡張して Python を直接書けるように
もう一つの工夫として、Pyxel では HTML を拡張し、HTML の中に Python コードを直接書けるカスタム要素を用意しています。<pyxel-...> のような独自タグを書くだけで、ブラウザがそれを Pyxel ページとして実行してくれる仕組みです。
つまり、「Pyodide があれば動く」というのは入口の話で、その先で Pyxel コアの WASM 化 + Pyodide との協調 + HTML 拡張 という工夫が組み合わさって、ブラウザの中で Pyxel が動くようになっています。
レトロな絵を描いている仕組み
「レトロな描画はどうやって実現しているんですか?」というのもよく聞かれます。「16 色しか使わないし、解像度も小さいから簡単でしょう?」と思われがちですが、シンプルに見せるために、裏ではけっこう細かい工夫をしています。
パレット方式の採用
Pyxel の画面は、ピクセルごとに「色そのもの」ではなく「色の番号(0〜15)」を持っています。各番号がどの RGB 色に対応するかは、別途用意したパレットで決まります。
この方式の便利なところは、パレットを差し替えるだけで全体の見た目をガラッと変えられることです。たとえばファミコンカラーのパレットにすればファミコンっぽい画面、MSX のパレットにすれば MSX 風の画面、といった具合に、コードを変えずに見た目を切り替えられます。
フレームバッファをそのまま読み書きする
pyxel.rect() や pyxel.line()、pyxel.blt()といった描画 API を呼ぶたびに、Pyxel は内部のフレームバッファ(画面サイズ分の色番号の配列)のピクセルを直接書き換えていきます。
Pyxel ではあえて 描画ロジックを GPU に丸投げせず、CPU 側でフレームバッファを直接読み書きする 方式にしています。GPU でレンダリングする現代的なエンジンに比べると速度は控えめですが、そのぶん柔軟性が高くなります。
たとえば、
-
pyxel.pget(x, y)で 1 ピクセルの色を読み出す - 自分で計算した結果をピクセル単位で塗っていく
- フレームバッファの一部を別の画像にコピーして加工する
といった、昔の BASIC 時代のような自由なお絵描き方法が、いまでもそのまま書けるようになっています。
その上で、内部ではこんな工夫も組み込んでいます。
- ディザ: 2 色を市松模様で混ぜて中間色っぽく見せる古典技法
- クリップ: 画面のうち決まったエリアだけを描画対象にする
- タイルマップ: 描画素材をタイル状に並べて描画する仕組み
- カメラ・パレット切り替え: 描画位置や色をその場で変える設定
表示の最後だけ GPU を使う
ただし、画面に出す 最後の一手だけは GPU を使って表示 しています。最終的に出来上がったフレームバッファをテクスチャとして GPU に渡し、画面に貼り付けるところで、拡大・縮小や特殊な表示エフェクトをかけられるようにしています。
「描画ロジックは CPU、表示は GPU」というハイブリッドな構成で、レトロな描き味と、現代らしい表示の自由度の両方を確保しています。
Alt(Option)+9で Pyxel の表示をブラウン管調などの変わった表現に切り替える機能がありますが、これは OpenGL(OpenGL ES)のシェーダー機能を使って実現しています。
描画もすべて GPU 化すれば実行速度はもっと上げられます。ただ、それをやると pgetやfillのようなピクセルの自由な読み書きの機能が失われてしまううえ、Pyxel が動かない環境も出てきてしまうので、GPU 化の境目はいつも悩みどころです。
ファミコン風サウンドを鳴らしている仕組み
「どうやってファミコンサウンドを再現しているんですか?」というのも、よくいただく質問です。
これは、録音した音をそのまま再生しているのではなく、ファミコンの音作りをリアルタイムにシミュレーションしている——いわゆるソフトシンセを自前で実装しています。
ファミコンを再現するソフトシンセ
Pyxel のサウンドエンジンの各チャンネルでは、こんな要素を組み合わせて音を作っています。
- 波形テーブル: 矩形波、三角波、ノイズ、ユーザー自身が形を編集できるカスタム波形
- エンベロープ: 音量の変化(立ち上がり / 減衰 / 持続 / 収束)
- ビブラート / グライド: 音を揺らしたり、なめらかに音程を変えたりするエフェクト
これらを Rust 側ですべて計算し、4 チャンネルの波形を足し合わせてスピーカーから鳴らしています。チャンネル数 x 秒間 22,050 個分の数値を、毎秒きっちり計算して時間ぴったりに再生しているわけです。
リアルタイムに波形を編集したり、同時発音数を自由に増やせるようにすることで、当時のゲーム機にはなかった自由度を実現し、ファミコンっぽい質感は守りながら、「もう少しこういう音色が欲しい」を作れるようにしています。
MML:文字列で音楽を書く言語
その上に、Pyxel では独自仕様の MML(Music Macro Language)という音楽記述言語を実装しています。
pyxel.sounds[0].mml("CDEFG")
このように CDEFG のような文字列を渡すだけで、ドレミファソが鳴らせます。テンポやオクターブ、音色の指定なども MML 内に書けるので、メロディ全体を文字列ひとつで定義できます。
裏側では、文字列を 1 文字ずつ読み込んで「これは音符 C」「これは 4 分音符の長さ」「これはオクターブを上げる指示」と解釈する 字句解析と構文解析 を Rust 側で行っています。文章を意味のかたまりに分解する処理を、音楽の文字列に対してやっているわけです。
レトロ調 × 当時はなかった編集環境
これらを組み合わせることで、見た目や聞こえはレトロながら、当時のゲーム機にはなかったサウンドの編集・再生の自由度を実現しています。
公式ツールとして用意している Pyxel MML Studio(ブラウザ上で MML を書いて試聴・共有できるツール)や、frenchbread さんが作られている Pyxel Composer(音色やメロディを GUI で編集できるツール)も、内部ではいま紹介したサウンドエンジンを使って音を出力しています。
最近では、frenchbread さんが作られた自動作曲機能もgen_bgmという関数名で Pyxel に取り込ませていただきました。これは音楽理論に基づいて動的に MML を作り出すアルゴリズムで、ゲームの状況に合わせてその場でメロディを生成して鳴らす、といった使い方ができます。
「チープなレトロ音を鳴らしているだけ」に見えますが、波形を一つひとつ計算してミックスしながら、当時はなかった編集の自由度まで盛り込んだシンセサイザー実装が裏で動いている、というわけです。
おわりに
ここまでで、Pyxel の裏側にどんな仕組みが入っているか、ざっと見ていただきました。
表側こそシンプルですが、その奥には Rust + Python の二層構造、Web 実行のための Pyodide との協調、CPU と GPU を組み合わせた描画、自前のソフトシンセと MML、と意外と細かい仕組みが詰まっています。「レトロ = シンプルな設計」というわけでもなく、裏側にはいろいろな工夫が詰まっていることを感じていただけたなら嬉しいです。
いまは、新たなメジャーバージョンである 3.0 に向けて、大きな新機能の実装を進めています。完成すれば、より一風変わったゲームを気軽に楽しく作れるようになる予定ですので、楽しみにしていてください。
Pyxel は、個人で開発を続けているプロジェクトで、皆さんのご利用やフィードバックが何よりの励みになっています。気に入っていただけたら、GitHub のスター を付けていただけるとうれしいです。
Pyxel は無料で使え、インストール不要のブラウザ版もあります。まだ触ったことがないという方は、ぜひこちらから気軽に試してみてください。
- Pyxel Showcase: 既存の作例をブラウザで遊べます
- Pyxel Code Maker: ブラウザ上ですぐにコードを書いて実行できます
もっと詳しく学びたいという方には、公式書籍 『ゲームで学ぶ Python! Pyxel ではじめるレトロゲームプログラミング』 もおすすめです。
それでは、よい Pyxel ライフを!