理由もなく JavaScript ゲームエンジンを C に移植
(phoboslab.org)- 2010年の JavaScript エンジン Impact の構造をよみがえらせた high_impact は、2D アクションゲーム向けの C 製エンジンで、Windows・Mac・Linux と Web 向け WASM をサポートする
- Impact は、iOS が Flash を排除していく流れの中で Canvas2D でも Web ゲームは可能であることを示すために作られたエンジンで、$99 の有料販売で 3,000 件以上のライセンスが売れた後、無料のオープンソースとして公開された
- 新しいエンジンは、タイルマップ、エンティティ、物理・衝突、スプライトアニメーション、テキスト、サウンドをひとまとめにした 小さなフレームワークで、SDL または Sokol バックエンドを使用する
- 実装は、固定サイズのエンティティストレージ、QOI/QOA アセット、単一の hunk メモリ、OpenGL・ソフトウェアレンダラー、JavaScript ベースの Weltmeister レベルエディターにより、シンプルさを保っている
- Biolab Disaster と Drop を既存の JS ソースに近い形で移植して実行でき、プラットフォーム・レンダラーの拡張を通じて複数のシステムへ広げていける
high_impact 概要
- high_impact は 2D アクションゲーム向けの小さなゲームエンジン
- C で書かれており、Windows、Mac、Linux、Web 向け WASM にコンパイルされる
- 2010年の JavaScript ゲームエンジン Impact に着想を得ており、名前は C が高水準言語と見なされていた時代を指している
- MIT ライセンスで公開され、ソースは GitHub にある
Impact が作られた背景
- 2010年4月、Steve Jobs は iOS で Flash をサポートしないという公開書簡 “Thoughts on Flash” を発表した
- 当時 Flash は、ブラウザプラグインベースの Web ゲームとアニメーション文化の中心であり、Newgrounds や Kongregate のようなサイトは Flash コンテンツに大きく依存していた
- Android の Flash サポートには問題が多く、Adobe もモバイルでの欠点を改善しようとする努力をしなかったと評価されている
- 「Flash がなければブラウザゲームもない」という認識があったが、Canvas2D API は
<canvas>に画像や図形を描画できた - Canvas2D は Apple/Safari がデスクトップウィジェットのレンダリング用途に作り、その後 Google と Mozilla がサポートし、Microsoft の Internet Explorer は遅れを取った
- この流れの中で Biolab Disaster が作られ、そのためにゲームエンジンとレベルエディターもあわせて開発された
Impact の販売と利用例
- Impact はコード整理とドキュメント整備を経て $99 で販売され、有料販売の決定には反発もあったが、3,000 件以上のライセンス販売につながった
- 多くの Web ゲームが Impact で作られ、商用クロスプラットフォームタイトルにも使われた
- 晩年には Impact が無料のオープンソースとして公開された
- high_impact は Impact をゼロから作り直しつつ、JavaScript ではなく C で書いたプロジェクトである
なぜ C なのか
- C はシンプルだが奥深い言語であり、「学ぶのは簡単だが極めるのは難しい」というゲームの特性に似ていると考えている
- 複数のプロジェクトを経て、C への関心が再び高まった
- もともと Impact は Godot、Unreal、Unity のようなエンジンと比べる規模ではなかったが、複数のゲームの堅実な土台として機能していた
- Impact を C で書き直すことは、楽しい練習として始まった
エンジン構造とアセット
- high_impact はできるだけシンプルな形で実装され、可能な限り少ないコードで構成する方針を持つ
- 基本機能は元の JavaScript エンジンと同じ
- タイルマップ の読み込み
- ゲームオブジェクトであるエンティティの生成・更新・描画
- エンティティ間の物理と衝突処理
- 衝突マップとの衝突処理
- スプライトシートアニメーション
- テキスト出力
- 効果音と音楽の再生
- ライブラリというより フレームワーク に近く、ゲームロジックはフレームワーク内部に書く
- 下層には
platformバックエンドがあり、現在は SDL または Sokol でコンパイルされる - ゲームコードは 1 つ以上の “scene” に入り、scene は関数ポインターを持つ struct である
engine_set_scene(&scene_game)を呼ぶと、エンジンが新しい scene を設定するscene_game.init()は一度呼ばれるscene_game.update()とscene_game.draw()は毎フレーム呼ばれる
- タイルマップと初期エンティティは
.jsonファイルから読み込むか、動的に生成できる - レベルフォーマットに JSON を選んだ理由は、元の Impact との 後方互換性 のため
- high_impact は画像に QOI、サウンドと音楽に QOA を使用する
- デモゲームの Makefile は PNG を QOI に、WAV を QOA に自動変換する
- 別途、画像・サウンドデコードライブラリを含める必要がない
- 今後ほかのアセットフォーマットをサポートする可能性もあるが、QOI/QOA のシンプルさはプロジェクトの方針によく合っている
エンティティシステム
- すべてのエンティティは、位置、速度、サイズなど high_impact が必要とするプロパティを含む同じ
entity_t structを共有する - すべてのエンティティのバイトサイズが同じなので、保存と管理がシンプルになる
- エンティティを動かすときは速度や加速度を設定し、残りはエンジンが処理する
- マクロを通じて、基本エンティティ struct にゲーム固有のプロパティを追加できる
- Biolab Disaster はエンティティタイプ別の struct を持つ
unionを使用する - Drop は追加プロパティを定義する必要がない
- Biolab Disaster はエンティティタイプ別の struct を持つ
- 各エンティティタイプは、関数ポインターを提供する
entity_vtab_tを持つ必要があるupdateは毎フレーム呼ばれるtouchは条件に合うほかのエンティティと重なったときに呼ばれる- 項目はいずれも任意
- エンティティストレージは固定サイズ
- デフォルトのアクティブエンティティ数は 1,024 個
ENTITIES_MAX定義で設定できる- エンジンは最大 64k エンティティまで容易に扱う
- 1 フレーム以上エンティティ参照を保持するには
entity_ref_tを使うentity_ref_tはuint16_t idとindexを持つ structentity_by_ref()で再びポインターとして解釈できる- 同じストレージアドレスに別のエンティティが入った場合を区別できる
uint16_tインデックスのため、アクティブエンティティの最大値は 64k に制限される
- C で単純な OOP、クラス、単一継承のような構造を実装する部分はややぎこちないが、high_impact はそれをできるだけ扱いやすくしようとしている
- エンティティロジックをタイプごとに一か所へ置く「素朴な」OOP アプローチは、これまで作ったゲームでは理解しやすく、うまく機能した
衝突検出と応答
- 単純な衝突処理は、新しい位置へ移動できるかだけを確認し、不可能なら止める方式だが、速い物体では不自然な動作が起こり得る
- 2D プラットフォーマーでプレイヤーが地面の 16px 上にいて、次の移動で地面の中に入ってしまう場合、空中で止まってから次のフレームで再び降りる なめらかな着地 のように見えることがある
- high_impact はエンティティのボックスをタイルマップに対して追跡し、正確な衝突地点を計算する
- この方式は単純な yes/no 判定より複雑だが結果はより良く、斜面タイルも扱える
- タイルに当たった後、残りの速度で 2 回目の追跡が必要になる場合がある
- たとえば斜めに地面へ接触すると
vel.yは0になるが、vel.xは残して床に沿って滑らせる
- たとえば斜めに地面へ接触すると
- エンティティ間の衝突は別途処理される
- パーティクルはタイルマップとは衝突するが、ほかのエンティティとは衝突しないようにできる
- 動くプラットフォームはほかのエンティティと衝突するが、衝突応答で動いてはならない
- broad phase の衝突検出はエンティティを
pos.x基準でソートする- 前フレームですでに大半がソート済みなので、挿入ソートのコストは低い
- ソート後、左から右へ走査し、
pos.xからpos.x + size.xまでの間にあるエンティティだけを検査する
- この sweep and prune 方式は、似た x 位置に多くのエンティティが重ならない限り高速
- 積み上がった箱の塔のように、多数のエンティティが同じ x 位置に集まると最悪ケースになる
- 縦スクロールシューティングのように別の軸のほうが合う場合は、
#define ENTITY_SWEEP_AXIS yで sweep 軸を変えられる
レンダリング
- high_impact は現在、OpenGL レンダラーと未完成のソフトウェアレンダラーを含む
- すべてのレンダリングは非常に薄い API を経由し、実際の描画呼び出しは単一の関数で行われるため、ほかのバックエンド実装は比較的シンプル
- 追加のレンダリングバックエンドがサポートすべき主要関数は、初期化、クリーンアップ、画面サイズ設定、フレーム準備・終了、quad 描画である
- テクスチャ処理のために mark、reset、create 関数も必要
- 機能はシンプルで quad しか描画できず、シェーダー効果は使えないが、このエンジンの目的には十分
- ソフトウェアレンダラーは 140 行のコードで、軸に整列した quad のみをサポートする
- OpenGL レンダラーは、1 フレームのレンダリングを 1 回の OpenGL draw call に収めようとする
- すべての quad を大きなバッファに集め、
glDrawElements()で一度に渡す - すべてのテクスチャを単一のテクスチャアトラスにまとめ、テクスチャの再バインドを避ける
- すべての quad を大きなバッファに集め、
- テクスチャアトラスは古い方式で欠点もあるが、bindless texture がどこでもサポートされているわけではないため使用されている
- high_impact は単一のテクスチャアトラスだけをサポートするが、サイズは
#defineで設定できる- モバイル GPU は通常 8k×8k テクスチャをサポートする
- 最新のデスクトップ GPU は 32k×32k までサポートするようだ
- Biolab Disaster と Drop は 512×512 アトラスを使用する
サウンド
- サウンド出力は SDL2 または Sokol が処理し、エンジンは読み込み・デコード・複数サウンドのミキシングを担当する
- サウンドシステムは、サンプルを持つ
sound_source_tと、現在再生中のサウンドを表すsound_tに分かれる - このシステムは wipEout 再実装時に作ったシステムをベースにしており、必要に応じて QOA を展開できる
- すべては静的に割り当てられる
- 読み込める source 数が固定される
- 同時再生可能な sound 数が固定される
- 再生が終わった sound は自動的に破棄され、再利用される
- サウンドは音量、左右パン、ピッチを変更できる
- ピッチを負の値に設定すると、サウンドを逆再生する
- 可変ピッチに必要なリサンプリングには、最近傍補間を使う低品質な方式を用いる
メモリ管理
- high_impact では、ゲームがユーザー生成アセットを持たない場合、必要なメモリ量を正確に把握できると考えている
- エンジンは “hunk” という単一のバイト配列を静的に割り当て、high_impact が使うメモリはこれだけである
- hunk サイズは
#define ALLOC_SIZEで設定できる - hunk では 2 つの方式でメモリを割り当てる
- 前方から上へ伸びる bump アロケーター、または arena がゲームアセット・エンティティ・現在の scene データを保持する
- 末尾側から下へ伸びる一時アロケーターが
malloc()とfree()のように動作し、画像を展開して GPU に渡す前の一時保存などに使われる
- bump アロケーターは複数の “high water mark” を持ち、特定の時点で自動的に巻き戻る
- bump 割り当てメモリを明示的に
free()する必要はない - 概念上の寿命は
game、scene、frameに分かれる- 最初の scene 設定前に割り当てたものは、プログラム終了時にのみ解放される
scene.load()中に割り当てたものは scene 終了時に解放される- scene 実行中に割り当てたものはフレーム末尾で解放される
- エンティティタイプ別の
load()は、どのエンティティが scene で使われるか事前に分からないため、第 1 段階で呼ばれる - 追加の割り当てコンテキストは
alloc_pool()で包むことができ、内部的にはbump_mark()とbump_reset(mark)の省略形である
Weltmeister レベルエディター
- 元の Impact には Weltmeister というレベルエディターがあり、high_impact にも含まれる
- 依然として JavaScript で書かれ、元のソースを多く使っているが、最新ブラウザ機能に合わせて更新されている
- Weltmeister は完全に独立して動作する
weltmeister.htmlをダブルクリックしてレベル制作を始められる- 以前はファイルの読み込み・保存に PHP または NodeJS バックエンド API が必要だった
- 現在は FileSystemAPI で特定フォルダへのアクセス権を要求できる
- Safari と Firefox は特に showDirectoryPicker() をまだ完全にサポートしていないため、Chrome 系ブラウザが必要
- Weltmeister は C ソースファイルを読み、エンティティタイプを収集する
- high_impact は、エディターは理解するが C コードでは何も動作しないマクロを提供する
EDITOR_SIZE(X, Y): エディター上でのサイズ、デフォルトは(8, 8)EDITOR_RESIZE(RESIZE): エディター上でサイズ変更可能かどうかEDITOR_COLOR(R, G, B): エディター上でのボックス色、デフォルトは(128, 255, 128)EDITOR_IGNORE(IGNORE): エディター上で生成可能かどうか
デモゲーム
- high_impact が実際のゲームエンジンとして動作するか確認するため、元の Impact ゲーム 2 本が C に移植された
- 移植作業は、既存の JS ソースを「音写」するように移す作業に近く、既存アセットを再利用した
- 課題が少なかった点は、high_impact が意図どおりに動作した根拠と見なせる
-
Biolab Disaster
- 元の Impact のローンチタイトル
- 横スクロール Jump'n'Gun ゲーム
- ソースは github.com/phoboslab/high_biolab にある
- 元の JS 版は playbiolab.com で提供されている
-
Drop
- 非常にシンプルなアーケードゲーム
- ソースは github.com/phoboslab/high_drop にある
- 元の JS 版は impactjs.com/drop/ にある
- 現在の最高スコアとして 26789 Points が示されている
拡張性
- high_impact は従来型のゲームエンジンのように、ゲーム別コードを 追加型 として書く構造である
- エンジンソースを変えなくてもよいが、必要ならエンジンソースを直接変更できるほどシンプルに作る方針
- プラットフォームとレンダラーは、残りのコード変更なしに拡張できるよう設計されている
- 関心があれば、Vulkan、DirectX、Metal レンダラーや、PSX、N64、Dreamcast のようなプラットフォームバックエンド対応の pull request を歓迎する
- C で書かれているため、どこでも実行できるべきだという方針を持つ
1件のコメント
Hacker News のコメント
自分がやってきたプログラミング作業の中で、最も学びが多かったもののかなりの部分は Impact のおかげだった
Impact は本当に時代を先取りしていて、3000人のライセンス保有者の一人だったことを誇りに思っている。自分がした買い物の中で最高のものだったし、きちんと最後まで完成させた唯一のゲームも Impact で作った
ライセンスにソースコードが含まれていたのがよくて、必要に合わせてエンジンやエディタも自分で修正した。その影響で数年間、自分の JS ゲームエンジンを作っていたが、肝心のゲーム完成は先送りになった。それでもその過程で多くを学んだし、ゲームジャム用のゲームもたくさん作った
Impact のネイティブ iOS 対応である Ejecta にも刺激を受けたが、当時 Android で動かないのがもどかしく、WebView なしで Android 上で自分のエンジンを動かそうとして、V8 用の JVM バインディングを作り、WebGL の一部を実装した。公開した V8 バインディングのリポジトリが、思いがけず商用ソフトウェアで使われることになった: https://github.com/namuol/jv8
Impact のビジネスモデルに触発されて、非公開 GitHub リポジトリへのアクセス権を売るブートストラップ型スタートアップまで試したが、その話は長くなりそうだ。ともあれ、Impact が C への移植で「現代」の Web に合わせてアップデートされるのを見ると、心が温かくなって楽しい。Web は変な時期だと言いたいところだが、Web が変でなかった時期を思い出せない
CrossCode は素晴らしいゲーム。Web 技術を使っていることは知っていたし、Nintendo Switch のハードウェアであれだけ性能が出ているのにはずっと驚かされていた
このエンジンもそこにある程度貢献していそうだ
こういう点がむしろ格好いい。開発者が自分のゲームに合わせてエンジンを変えられるのは良いことで、同じように high_impact も「機能完備型」のゲームエンジンというより、便利な出発点として見るべきだ
面白い逸話として、みんなが Switch 版を望んでいたが、技術的な制約のためチームは「Hedgehags が飛び方を覚えたら CrossCode が Switch に出る」と答えていた: https://www.radicalfishgames.com/?p=6581
最終的に移植が実現したときには、「A switch in attitude」という追加クエストが入り、予想どおり空飛ぶ hedgehags が登場した: https://www.radicalfishgames.com/?p=6668
「Thoughts on Flash」は、Web プラットフォームが最も必要としていた瞬間、つまり単一ソフトウェアによる支配が徐々に大きくなっていた時期に、Web を救ったのかもしれない
その中には、Windows のはるかに大きなユーザー基盤を優先するあまり MacOS 対応を放置しているように見えた Adobe への不満もあったのだと思う。たとえば Mac 版は常に Windows 版より遅れていた
Jobs は、Apple が Adobe を可能にしたのと同じくらい Adobe も Apple に貢献したと感じていたのかもしれないが、それは推測に近い。ゲーム自体は本当に滑らかに見える
ただしアセンブリ言語ほど近いわけではなく、もっと深く掘り下げたくなる方向へ、いろいろ削っているところだ
企業勤めから抜け出して、いつかサイドプロジェクトに本格的に飛び込みたい立場として、有料化して自立した部分についてもっと聞きたい
もともと趣味でやっていた作業にお金をもらうという考えには妙な重さを感じるが、それが自分の好きなことをフルタイムでできるようにしてくれる可能性があるのもわかっている
だから、その考えがなぜ重く感じられるのかを把握することが重要だ。よくある理由としては、周囲の人がよく止めてくる、やりたいことをうまく実行する技術が足りない、助けを求めるのが恥ずかしい、相手に迷惑になりそうだ、自分の仕事が評価されるのが怖い、現在の収入がもたらす「安全」を失うこと、特に扶養家族がいる場合などがある
多くの場合、そうした理由はとても良い理由というより、ある程度の再調整が必要で、それが「リスク」のように感じられるためにコンフォートゾーンを離れにくい、ということに近い。そういう心構えだと、あらゆる機会がリスクに見えるので、本当にやりたいことを始める適切なタイミングを見つけるのが非常に難しくなる
関連して、面白いと思うことをただやって世の中に見せるのは重要だが、それを生計の立つ仕事に変えるのはまったく別の挑戦だ。多くの人は好きなことを生業にはできず、たとえそうなったとしても、有料顧客の期待や売上維持のプレッシャーがその愛着を奪ってしまうこともある。やめろという意味ではなく、飛び込む前に知っておくとよい点だ
ほとんど使っていない HN アカウントにログインするくらい、数年前に Biolab Disaster を何度も繰り返しプレイしていたのに、名前を忘れていた
偶然また見つけられたのは、かなり不思議な感じがする
普段なら、私はこれをもっと否定的に表現していました。「フレームワーク」とは単に、ほかのものとうまく噛み合わないライブラリだと見ていたからです
それでも、こういう納得できる前向きな表現を聞けたのはよかったです
必要なことの99%を本当にうまく処理してくれる、豊富でよく設計されたフレームワークを使うのは楽しいものでした。自分のコードを追加するのも、これまで経験した開発の中でかなり簡単な部類で、ただうまく動きました。魔法のようで、今でも懐かしく思います
私が考える理想的なフレームワークは、内部的にはライブラリ、または協調するライブラリ群であり、フレームワーク的な性格はできるだけ少ない形のものです
たとえば Qt はフレームワークで、Qt が「私を呼び出す」わけですが、Qt のイベントループを起動したり QObject について深く考えたりしなくても、QPainter のコードを実行できます。理想的には、シグナルとスロットを全面的に受け入れなくてもイベントループを使えるべきで、ただし使い勝手は多少悪くなるかもしれません
常に可能なわけでも、常に価値があるわけでもありませんが、ほかの条件が同じなら、フレームワークがまったくないほうを好みます
ゲームエンジンでは、多少のフレームワークが必要になる理由は理解できます。携帯電話やコンソールのような特殊なプラットフォーム向けにコンパイルするときは、エンジンがビルドプロセス、場合によっては libc 関連の部分にまで関与する必要があるため、単に Win32 実行ファイルを作って PlayStation ゲームだと言うわけにはいきません
QOI ロスレスファイル形式に 7Zip を組み合わせると、ロスレス PNG より性能がよくなります。驚くべき仕事です
「for No Reason」は、おそらくプレイヤーのバッテリー寿命を尊重するためだったのかもしれません
メモリ管理の部分が気に入りました。アリーナアロケーションは本当に単純です
私が使っているおもちゃの Web サーバーも最初はアリーナから始めましたが、すぐにメモリを増やしたり減らしたりする必要自体がないことに気づきました
今は必要なメモリを最初にまとめて確保し、その後、各モジュールで使う断片に分けています。プログラミングでは、任意の量のメモリが必要になる可能性があると考えがちですが、必ずしもそうではありません
多くのものには実際には明確な上限があり、残りについてもたいてい上限を定義できます。それを列挙すれば、必要なメモリ量が分かります。そうした上限をあらかじめ考えて定義するのは楽しく、自信にもなり、健全な節約習慣を育ててくれます
空のベクターから始めて、メモリ予約なしに何千もの項目を追加すれば、当然そうなり得ます。しかし実際に必要な最大値を見つけて事前に確保できる場合は非常に多く、そうすればすべて問題ありません
union で多相的な型の ENTITY データ構造を作ったやり方が本当に気に入りました。設計がよいです
今でも C をいじるのは好きです。最初に学んだ言語で、数年間本当に苦労しました。元記事でも述べられているように、C は簡潔な言語だからこそ格好よく、望めばいくらでも深く潜れます
ゲームは昔の Commander Keen のような雰囲気があってよかったですし、Carmack が 3D 以前の時代に作っていたあのフランチャイズを、かつてかなり気に入っていました