1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIの出題クイズから学ぶ、ゲーム開発におけるC++と最適化の基礎

1
Posted at

免責事項
本記事の内容は個人の見解であり、所属する企業の公式な見解や方針を代表するものではありません。
また、本記事の内容は完全に個人の趣味や学習の範囲で行っているものです。
本記事で紹介する取り組みは、業務とは切り離された個人のローカル環境およびプライベートリポジトリで実行しています。
内容には正確性を期していますが、誤りや不適切な記述が含まれている可能性があります。お気づきの点がございましたら、ご指摘いただけますと幸いです。

はじめに

以前の記事で、Antigravityを活用してAIを専属コーチに仕立て、毎日ゲーム開発技術のお題を出題させる学習環境を構築した経緯を紹介しました。
当時はAntigravity CLIを用いて手動で出題コマンドを実行していましたが、現在はAntigravityデスクトップアプリへと移行しています。
さらに運用面も少し改善し、スケジュール機能(Cronジョブ)を活用して「毎朝7時にAIが自動でお題を生成してチャットに届けてくれる」という形に切り替えました。

こうして毎朝の学習を続け、日々の出題ログは約90問に達しました。
出題される内容も、最初は基本的な構文の確認が多かったのですが、次第にメモリ管理やCPUキャッシュといった、パフォーマンスや最適化にまつわるテーマも増えてきました。

私は普段、ゲーム業界とは異なるIT企業でエンジニアとして働いており、ゲーム開発技術については趣味や知的好奇心として学習を進めています。
そのため、C++の一般的な文法はある程度知っていても、ゲーム開発特有のコードや解説を読むと「なぜわざわざこんな書き方をするんだろう?」と疑問に思うことがよくありました。
しかし出題を解いていく中で、メモリの断片化やCPUキャッシュといった背景を教えてもらい、その理由が少しずつ腑に落ちるようになってきました。

今回は、これまで解いてきた約90問の中から、私自身が特に「なるほど」と感じたクイズを2つ紹介します。
AIコーチが出題した問題と、当時の私の思考、そしてAIコーチからの解説をまとめました。

クイズで学ぶC++と最適化の考え方

第1問:毎フレームの動的確保を抑える「フレームアロケータ」

AIコーチからの出題

ゲームの実行中には、「そのフレームの間だけ使って、次のフレームにはもう不要になる一時的なデータ」が毎秒大量に生成されます。
たとえば、当たり判定計算用の一時配列、AIの経路探索候補リスト、デバッグ描画用テキストなどが該当します。

これらの一時データに対して、毎フレーム通常の new や delete(あるいは malloc や free)を何百回、何千回と繰り返すと、OSのメモリアロケータがボトルネックになります。
さらにヒープ領域の断片化(フラグメンテーション)が進み、ゲームのカクつき(処理落ちスパイク)を引き起こす原因になります。

この問題を避けるための設計パターンの1つとして、ゲーム開発ではフレームアロケータ(リニアアロケータ)と呼ばれるカスタムメモリアロケータが用いられることがあります(たとえばUnreal Engineにおける FMemStack など)。

では、このフレームアロケータが通常のメモリアロケーションと比べて「高速」かつ「断片化を防ぎやすい」理由として、最も適切な説明はどれでしょうか?

  1. 確保したメモリブロックを常にLRUアルゴリズムで監視し、未使用の隙間を自動で詰めて再配置するから。
  2. あらかじめ確保した巨大なメモリ領域に対し、確保時はポインタを進めるだけで、解放時はフレームの終わりにポインタを先頭へ戻す(全破棄する)だけだから。
  3. 内部にハッシュテーブルを持ち、OSを介さずに独自のガベージコレクタがバックグラウンドスレッドで世代別回収を行うから。
  4. 一時データをすべてCPUのL1キャッシュとGPUのVRAMに直接マッピングし、物理メモリアドレスの解決を省略するから。

当時の私の思考と回答

実はこの問題、以前AIコーチから「リニアアロケータの実装課題」を出題されたことがあり、どこかで見聞きした知識と合わさってピンときた記憶がありました。
「あらかじめ確保した領域の上でポインタを進め、フレームの終わりにオフセットをゼロに戻すだけで使い回す」という仕組みを実際にコードで触っていたため、迷わず 「2」 と回答できました。

AIコーチからの解説

正解は 2 でした。
AIコーチから提示されたコードの骨格は、以下のような簡潔なものでした。

class FrameAllocator {
private:
    char* buffer_ = nullptr; // 起動時にまとめて確保したバッファ(例:16MB)
    size_t offset_ = 0;      // 現在の使用位置

public:
    void* Allocate(size_t size) {
        // ポインタ(オフセット)を進めるだけ
        void* ptr = buffer_ + offset_;
        offset_ += size;
        return ptr;
    }

    void Reset() {
        // フレームの最後にオフセットをゼロに戻すだけ
        offset_ = 0;
    }
};

通常の malloc は「メモリのどこに適切なサイズの空きがあるか」を管理テーブルから探すため、呼び出しごとにコストがかかります。
また、異なるサイズを不規則に確保や解放をし続けると、メモリが虫食い状態(断片化)になってしまいます。

一方、「フレームアロケータ」はあらかじめ確保しておいたひとまとまりのメモリ領域の上を、オフセットを加算しながら進むだけです。
そしてフレームの末尾で offset_ = 0 とするだけで、そのフレームで確保された一時メモリが一括で再利用可能になります。

この問題で納得したポイント

この仕組みを初めて知ったとき、非常に合理的だなと感心しました。
「メモリの寿命が全員同じ(フレーム終了時まで)なら、1つずつ個別に解放しなくても一括で管理できる」という考え方です。


第2問:直感に反する?CPUキャッシュのための「SoA(配列の構造体)」

AIコーチからの出題

ゲーム開発において、CPUキャッシュに乗せやすくして処理を高速化するデータ指向設計(Data-Oriented Design:DOD)という考え方が注目されています。

従来のオブジェクト指向プログラミングでは「キャラクターのデータ(位置、速度、色、寿命など)を1つのクラスにまとめる」のが自然ですが、DODでは「同じ種類のデータをメモリ上に一列に並べる」ことを重視します。

大量のパーティクルを管理するにあたり、以下の2つのメモリ配置パターンがあります。

  • 方式A(AoS:Array of Structures):[位置, 速度, 色, 寿命], [位置, 速度, 色, 寿命]... と、オブジェクトごとにまとめた構造体を配列にする方式。
  • 方式B(SoA:Structure of Arrays):[位置, 位置, 位置...], [速度, 速度...] と、属性ごとに独立した配列を用意する方式。

パーティクルのうち「位置と速度だけを更新する(色や寿命は触らない)」ループを毎フレーム大量に回す場合、DODで推奨される 方式B(SoA) のほうが処理速度において有利になりやすい理由として、最も適切なものはどれでしょうか?

  1. SoAにすると、コンパイラがすべてのデータを自動的にGPUの頂点バッファへ転送してくれるから。
  2. AoSでは1つのパーティクルを参照するたびに使わない属性(色や寿命)までキャッシュラインに載ってキャッシュを圧迫するが、SoAでは位置や速度だけが連続して並ぶため、キャッシュミスが減るから。
  3. SoAにすることで構造体のアライメント用パディングが完全にゼロになり、メモリ消費総量が半分以下になるから。

当時の私の思考と回答

オブジェクト指向プログラミングを学んできた感覚からすると、方式A(AoS)のほうが直感的で書きやすいと感じます。
「キャラクター」や「パーティクル」というひとまとまりの実体があるのに、属性ごとにバラバラの配列に分解する方式B(SoA)は、あまり直感的ではないように感じられました。

しかし、「CPUキャッシュ」というキーワードから、以前学んだ「CPUはメモリからデータを取ってくるとき、周辺のデータもまとめて読み込む」という性質を思い出しました。
位置の計算をしたいときに、関係のない色や寿命のデータが一緒に読み込まれたら無駄になるはずです。
そこで 「2」 と回答しました。

AIコーチからの解説

正解は 2 でした。

CPUはメインメモリからデータを読み込む際、1バイト単位ではなくキャッシュライン(一般に64バイト単位)と呼ばれる塊でデータをCPUキャッシュに取り込みます。

方式A(AoS)の場合、1つのパーティクル構造体が48バイトあったとすると、64バイトのキャッシュラインには1個ちょっと分のパーティクルしか載りません。
しかも「色」や「寿命」のデータがキャッシュの半分近くを無駄に埋めてしまうため、次のパーティクルを処理するたびにメインメモリへデータを取りに行く「キャッシュミス」が発生します。

一方、方式B(SoA)では、位置データ(Vector3で12バイト)だけがメモリ上に隙間なくズラッと並びます。
そのため、64バイトのキャッシュライン1本に約5個分の位置データが連続して格納されてCPUに届きます。
CPUはメモリからの読み込み待ちを減らし、スムーズに計算を進められるようになります。

この問題で納得したポイント

この問題を通じて、クラス設計としての分かりやすさと、CPUにとって扱いやすいデータの並び順には違いがあるのだと実感しました。
コードの見た目としては少し扱いにくく見えても、大量のデータを一括で更新する場面では、CPUのキャッシュに配慮した並び順を考える理由が理解できました。


クイズ形式で継続して得られた実感

毎朝7時にお題が届き、1日1問だけ考えてAIコーチと対話する。
このサイクルを数ヶ月継続してみて、学習のあり方についていくつか実感したことがあります。

1. 「なぜそのコードを書くのか」を実行時の仕組みから考えるようになった

C++の文法を少しずつ覚え始めた段階でも、「このコードはメモリやCPUにどう負荷をかけるのか」といった実行時の挙動が気になってきます。
1問1答でAIコーチから具体的な仕組み(キャッシュラインのサイズやアロケータの挙動など)に触れることで、少しずつ内部の動きを想像しながらコードを読む意識がついてきました。

2. 朝の5分から10分で無理なく思考力が維持できる

難しい技術書を通読しようとすると、まとまった時間が取れずに途中で挫折してしまいがちです。
しかし、毎朝チャットに届く1つのクイズであれば、始業前や移動時間のわずかな隙間で集中して思考できます。
間違えたり迷ったりしても、AIコーチがその場で分かりやすくフォローしてくれるため、挫折感なく続けられました。

おわりに

本記事では、AIコーチとの毎朝の学習ログの中から、特に学びになったC++と最適化のクイズを2問選んで紹介しました。

最後に、AIを活用した学習との付き合い方について、少し触れておきたいと思います。

生成AIを活用している以上、出力された情報の裏取りの必要性は常に感じています。
ただ、「毎日気軽に学習する習慣をつける」という目的からすると、毎朝出題されるたびに専門書やドキュメントを細かく調べ直すのは、継続のハードルを上げてしまい本末転倒になってしまいます。
そのため、この毎日の出題だけで完結させず、並行して関連する技術書(『Game Programming Patterns』や『ゲームプログラミングC++』など)を少しずつ読み進めるようにしています。

それらの書籍を読んでいる限り、少なくとも今回のクイズのような基礎的な知見であれば、AIが大幅に的外れなことを言っている様子はありませんでした。
そのため、学習のきっかけ作りとしては現状このスタイルを受け入れています。
しかし、無批判に鵜呑みにしていると足元をすくわれるリスクは常にあります。
もし今後、個人の趣味の範囲を超えてこれらの知見を活用するような場面がやってくるなら、なおさら慎重な検証が必要だと感じています。

本記事で紹介した「AIコーチからの出題」や解説についても、大まかな確認は行っているものの、完全に厳密な裏取りができているわけではありません。
記述に不正確な点や違和感のある箇所、あるいは「こういうアプローチもある」といったご意見がありましたら、ぜひコメント欄でご指摘いただけますと幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?