目次
1.はじめに
2.エラーメッセージ付きassert
3.Slot map
4.Sparse set
5.traitによる静的実装分岐
6.おわりに
はじめに
この記事はCCS Advent Calendar 2022の21日目の記事です。
昨日の記事は、kakira9618さんの「 AtCoder の過去問埋めを超楽にするサービスを作った話(ダイジェスト)」です。
競プロクソ雑魚でWA×2を一生出している民なので、非常にありがたく凄いサービスな気がします。活用してみたいです。
明日の記事は、Zukashunさんの「ChatGPIはウミガメのスープの味を知るか?」です。ChatGPTは自分もいじってみましたが正直かなり面白いので、注目記事です。
お初に(裏の記事を見ていただいた方は2度目に)お目にかかります。
20(編入)のゆういちろうと申します。低レベル描画周りとC++が好きで色々勉強していますが、コードより恥をかく方が得意です。
この記事では、筆者が割と好んでいる技法、設計、データ構造等を紹介します。以下、注意点です。
- 新しいアイデアとかではありません。筆者がせっせと作っているのは全て車輪です。ググれば素晴らしい解説がたくさん出ます。
- はちゃめちゃに嘘や誤りを含む可能性があります。そのマサカリで撃ち抜いてください。
- 主にC++の話です。(別の言語で使える話もあります)
- 多分、知ってから使うまでのコストが低い順に並んでいます。
- そんなことよりまずDoxygenを使いましょう。こんなボロ記事を読んでいる場合ではありません。
エラーメッセージ付きassert
C/C++にはassertというマクロがあります。(assert.h/cassertに定義されています)
assertの簡単な説明
assertマクロは、「そこでは必ずそうなっていてほしい」条件を主張(assert)するマクロです。例えば、
void hoge(int foo)
{
// ここでfooがbarなのはあり得ない話し!
}
のようなとき、
void hoge(int foo)
{
assert(foo != bar);
// ここでfooがbarなのはあり得ない話し!
}
と書くことで、foo == barであれば(assertマクロに渡された条件式がfalseであれば)、そこでfalseとなった条件式、コードのファイル名、行数等の情報を表示してプログラムが停止します(abort関数が呼ばれます)。また、assertマクロはNDEBUGマクロが定義されていれば(いわゆるDebugビルドでなければ)実行されません。
説明終わり---------------------------------------
一方、C++にはstatic_assertという、コンパイル時に与えた引数(条件式)がfalseならコンパイルエラーとする関数があります。この関数はコンパイル時にしか使えませんが、条件式とともにエラーメッセージを指定できます。assertではfalseと評価された条件式が表示されるのみなので、そのassertでなぜ停止したのか分かりにくいです。また,assertの近くにコメントを書くというやり方でもエラーメッセージの伝達は可能なのですが,そのファイルが開けない・開きにくい場合や行数覚えるのめんどくせえ!という場合もあります。というわけで、これ、assertでも欲しいですね!
assert(条件式 || !"エラーメッセージ");
こう書きます。すると、このassertが発生した場合、
Assertion `条件式 || !"エラーメッセージ"' failed.
と表示されるため、エラーメッセージも確認できます。
いちおう理屈
理屈終わり---------------------------------------
条件式 && "エラーメッセージ"のほうが分かりやすくね?
回答終わり---------------------------------------
Q. そもそもassertなんて使わずに例外使った方がいいんじゃない?
A. リアルタイムな感じだと例外なんて重くて使えねえよ!!!!!(言い過ぎ)
noexceptは、代表的には以下の2つの用途で使用できる:
- パフォーマンス向上
例外を送出しないという保証があることで、コンパイラは例外送出によるスタック巻き戻しのためのスタックを確保する必要がなくなる
(こちらより引用)
大して意味ないらしいですねこれ
Slot map
これは連想コンテナの1種であり、C/C++に限った話ではありません。(Rustには標準で用意されています)
C++による実装はこちら等にあります。(ググったらたくさん出ます)
ここでは、このコンテナの特徴と、雑な実装の紹介をします。
(雑でいいならすぐ作れます。ただしslotの実装のやり方によっては配置newでメモリを塗るので、注意が必要です。筆者は飲み会でオールした日の午後に作った結果、生活リズムとヒープのcorruptionがdetected)
特徴
このコンテナの特徴は、キーの型が予めコンテナ側によって決められていること(たいてい大きめの符号なし整数)、要素の挿入/削除/アクセスが全てO(1)であることです。ゲームにおけるオブジェクト管理等、オブジェクトに対する弱い参照が必要な場合に最適です。
雑な実装紹介
格納する要素の配列(ここではページとする)の配列、つまり2次元配列に対し、その要素のIDを
ID = (ページを指すindex) × (各ページのサイズ) + (各ページ上での要素のindex)
とすることで、除算(ページがわかる)と剰余算(ページ上での位置がわかる)のみで各要素にアクセスできるというものです。しかもページのサイズを2のべき乗に制限すればビットシフトとビットマスクだけで済むため、さらに高速化可能です。コンテナ側では、現在フリーな場所のIDをqueue等に記録しておき、要素の追加ごとにIDを渡してpopします。もしフリーなIDがなければ、新しいページを確保し、そのページ上の要素のIDをpushします。要素を削除するときは、デストラクタを呼んだのち、そのIDをフリーなものとしてpushします。
しかし、これだけでは、一度削除した要素のIDを再利用した場合、直前に削除したIDと重複してしまい、なんかワイルドでいい感じになる可能性があります。そこで、このIDと、スロットと呼ばれる整数値を合わせた組をキーとします(64bit符号なし整数をキーとし、その半分の32bitをID、もう半分の32bitをスロットとする実装が一般的だと思います)。このスロットを、要素が削除され、IDが再利用されるごとに1加算してIDと組み合わせ、新しいキーとします。これにより、少なくともスロットの最大値までは、キーを再利用しても値は重複しません(64bit符号なし整数キーではスロットは32bitなので約21億回です)。正しくスロットのデータ型を決めればこの重複は現実的でないので、不正アクセスを防げるという仕組みです。
他の説明はこちら(Internet Archiveですが)等にあります。
Sparse set
こちらも(ほぼ)連想コンテナです。実装は超簡単です。そして例に違わず、Rustには標準で用意されています。C++での最強実装はこちらにあります。
感覚としてはバケットを用いるアルゴリズムに近いと思っています。Entity Component System(ECS)というゲーム設計パターンがあり、(筆者はここ1年ほど執心しています)その実装スタイルには主に2つの宗派があるのですが、その片方の根幹を成す非常に重要なものです。しかし、他の用途があまり思いつきません。適用可能な状況が限られ過ぎています。
It can be useful in some very narrow and specific cases,
namely when the universe of possible values is very large
but used very sparingly and the set is iterated often or cleared often.
(こちらより引用)
特徴
このコンテナの特徴は、キーが単純にvectorのindexであり、要素が入っていないキーを大量に生成したとしても、値は実際に存在する要素の数だけしか確保されないことです。そのため、全走査が高速です。(ただしキーは大量に確保されます。)
挿入/削除/アクセスは、それぞれvectorに対する同じ操作2回分程度の計算量で行えます。
雑な実装紹介
vectorを2つ用意します。
※ECSでは諸般の理由により3つ必要なため、3つ出てくる例が多いかもしれません。
1つ目はsparse(疎)なindex、2つ目はdense(密)な実際の要素をそれぞれ格納するものです。
std::vector<std::size_t> sparseVec;
std::vector<T> denseVec;
こんな感じです。以下、一瞬で終わる各操作の実装説明です。
挿入するとき
// 注 : keyは消されたindexから再利用したり新しくsparseVecをリサイズしたりして作る
// 再利用する場合は、SlotMapと同様の不正アクセス防止措置を施すなど
sparseVec[key] = denseVec.size();
denseVec.emplace_back(args...);
アクセスするとき
denseVec[sparseVec[key]] = T();
削除するとき
// 要素が整列されている必要がない場合はswap_remove(backとswapしてからpop_back)すればO(1)です
denseVec.erase(denseVec.begin() + sparseVec[key]);
つまりsparseVecは(denseVecの)indexが入ってたり入ってなかったりするスカスカ(疎)なvectorで、denseVecは実際に確保されている値が連続して(密)格納されているvectorです。
使い道
筆者がこいつの主な使い道について話をしようとすると、ECSとはなんぞやという話になってしまいます。そのため、ECSの話をします。隙を見せたあなたが悪いです。
ECS雑説明
ゲームを以下の要素に分けて設計しようという考え方です。つまりECSという名前は構成要素の名前を単に並べただけです。よくありますよねこういうの。
- Entity
実体です。それ単体では何も持ちません。実装としては大体ただの整数値IDです。ライブラリから渡されます。 - Component
ゲーム内に現れるEntityの様々な特性を表すデータです。なんでもOKなのが理想です。自作する場合はヘルパ的なメソッド以外あんまり生やしません。Entityに追加することでEntityの実際の振る舞いを決定します。実装としてはstructが一般的です。 - System
ゲーム世界における作用を、その作用が対応するComponentに対して実行します。物理演算とか描画とか弾当たってたらHP減らすとかです。実装としては、Componentの参照を受け取ってなんかするような関数オブジェクトが一般的です。
実際には、これに加えて上記3つを全て内包する、いわゆるSceneに当たるデータ構造が存在します。WorldとかRegistryとか言う気がします。
このComponentをEntityに対応させて保存するためにSparse setを用いるのが、2大宗派のうちの1つ、Sparse set-based ECSです。EntityをIDとし、Componentを格納します。Componentの型とSparse setを対応付ける方法はCTTIによる型ハッシュ値を用いたハッシュマップお察しです。
使い方は例を見るのが早いと思います。10000個のキューブが落ちるっぽいコードを書いてみます。こんな感じです。
// 定数
constexpr std::size_t kNum = 10000;
constexpr float kGravity = -9.8f;
Registry registry;// ここに今のシーンで動くものを入れていく
std::vector<Entity> cubes(kNum);// 各キューブのID(Entity)たち
// キューブ作成
for (std::size_t i = 0; i < kNum; ++i)
{
auto cube = registry.create();
registry.add<RigidBody>(cube, RigidBodyType::Cube, 10.f, 5.f);// 辺の長さと質量的なやつ(適当)
registry.add<Transform>(cube, 1.f * (i / 10) , 10.f, 1.f * (i % 10));// (x, y, z)的なやつ(適当2)
cubes.emplace_back(cube);
}
// メインループ(終了条件は適当)
while(!finished())
{
// これがSystemにあたる、これでRigidBodyを持つすべてのEntityに対して重力がはたらいたことになる
registry.each<RigidBody>([kGravity](RigidBody& rigidBody)
{
// なんか物理エンジンに大体あるこういうフラグ
if (rigidBody.isDynamic())
{
rigidBody.addForce(0, rigidBody.mass * kGravity, 0);
}
});
auto view = registry.view<RigidBody, Transform>();
view.each([](RigidBody& rigidBody, Transform& transform)
{
// めんどくさくなった
// なんかここでRigidBodyのforceとか解決してtransform.accに反映してください
});
// 描画!?めんどくさくて書けるわけありません!
}
当然ですが、もうちょっと色々(というか何でも)できます。ググっていただければ...
有名どころだとMinecraftやOverwatchがこの設計であることが公開されています。他にもあると思います。
で、これの何がそんなにええねんという話なのですが、主な利点はコンポジションです。アホみたいな継承の沼にハマりません。でもそれなら別にUnityとかUEとかの普通のコンポーネント指向でいいじゃん(EntityもComponentもめっちゃクラスで、EntityがComponentのリスト持ってる感じ)という全体の95%ぐらいの方のために、コンポーネント指向と比較した場合の利点を以下に示します。
- キャッシュ効率が良い
同じ型のComponentは内部でSparseSetによって常に連続しているため、キャッシュラインに乗せやすく、イテレーションが速いです。 - 並列化しやすい
Componentは(基本的に)自分で何かをしないので、Systemどうしの干渉のみ考えればSystemを並列実行できます。 - シリアライズしやすい
Componentとそれに対応するEntity(つまり全SparseSet)をシリアライズしてしまえば、理論上そのシーンを丸ごと保存できます。
なお、当然欠点もあります。
- Systemどうしの連携(通信、処理順序決めなど)がめんどくさい
- 大量にComponentがあるような場合でなければそんなに高速化できない
- そもそも設計しづらい(わざわざオブジェクト指向としてデータと手続きをまとめたはずなのにもう一回分離してる)
ちなみにもう一方の宗派はArchetype-basedといいます。こちらは簡単に説明すると、Entityを、構成するComponentの組み合わせ(Archetype)ごとのメモリブロック(Chunkなどと呼ぶ)に保存し、その中で同じComponentを連続させる方法です。あまりEntityの構成が変わらない場合はいいですが、頻繁に変わる場合はメモリブロックを移動するコピーが発生しまくります。まあ一長一短で、どちらが良いというものではないらしいです。ただし、C++における実装のしやすさでは圧倒的にSparse set-basedが勝ります。(主観)
さすがにもうやめます。興味ある!と思ってくださった方は、自分に様々な方法で凸して頂けると有難いです。一緒に勉強したいです。
自分が作ったのはこれらです。
Sparse set-basedなほう
Archetype-basedなほう
以下参考文献などです。
https://tech.cygames.co.jp/archives/2843/
https://zenn.dev/rita0222/articles/c22a8367e31b4d5f4eeb
https://github.com/skypjack/entt/wiki
https://zenn.dev/toyboot4e/books/making-toy-ecs/viewer/intro
C++の記事って書いてるのにRustの記事を参考にするのはどうなんですか?
ECS説明終わり---------------------------------------
traitによる静的実装分岐
traitに関する話は、ググれば本当にいくらでも出てきます。ただし大体黒魔術です。筆者にはわけがわかりません。
ここではtraitを用いた、バックエンドとするAPIが変わる場合の簡単な設計パターンを紹介します。有名なやつです。お客様の中にマサカリをお持ちの方はいらっしゃいませんか。いたら危ないので降りてください。
/**
* @brief Vulkan APIを表す型
* @details APIとしてこの型を渡せばVulkan APIをバックエンドとして動作する
*/
struct Vulkan
{};
/**
* @brief DirectX12を表す型
* @details APIとしてこの型を渡せばDirectX12をバックエンドとして動作する
*/
struct DirectX12
{};
/**
* @brief APIのtrait
* @details この特殊化を用いてAPIごとの実装分岐を表現する
* @tparam API バックエンドとして動作させたいAPI型
*/
template<typename API>
struct APITrait;
template<>
struct APITrait<Vulkan>
{
constexpr static auto APIName = "Vulkan";
using Device = VulkanDevice;
using WindowImpl = VulkanWindow;
using VRAMAllocatorImpl = VulkanVRAMAllocator;
// 略
};
template<>
struct APITrait<DirectX12>
{
constexpr static auto APIName = "DirectX12";
using Device = DirectX12Device;
using WindowImpl = DirectX12Window;
using VRAMAllocatorImpl = DirectX12VRAMAllocator;
// 略
};
で、実際にユーザが使用する型では、
template<typename API>
class Device
{
using APIDevice = APITrait<API>::Device;
// 略、APIDeviceをメンバとして持ち、処理をいい感じにAPIDeviceにリダイレクトする
}
のようにすれば、内部実装が使用するAPIに応じて展開されます。静的な処理であるため、当然ながらvtableによるコストがありません。インタフェースはテンプレートクラスなので、多分各メソッドはinlineになるはずです(理解が浅くてすいません)
また,APIごとの定数やヘルパ関数等もここに定義すればいい感じに共通化できます。代わりに、当然ですが実行中にバックエンドを変更することはできません。そんな状況を考えたくありませんが... まあ、いい感じにis-aを避けられているということで許してください。委譲にあるべき柔軟性がねえだろ
type_traitsの各種traitや、SFINAE、C++20であればconcept等を用いて、使用できるAPI型を制限したり、逆に特殊化で定める各クラスが適格であれば後からAPIを追加実装することも可能であり、柔軟性も高いです。
おわりに
以上です。おまけとして、C++20を最近になってちゃんと使い始めた結果、std::spanの存在を知らなかった筆者が揃えて脱いだ靴とともに浜辺に遺したゴミを置いておきます。(言い訳していいですか?std::spanはinitializer_listを普通にはとれないじゃないですか...)
C++20の浜辺でちゃぷちゃぷしていた者の末路
ArrayProxy(見出しにするとバレるので修正しました)
C++で、なんか連続したりしなかったりする(要素数1以上の)データを受け取りたいときがあります。そんなとき、
- その要素1つだけのオーバーロード
- Cスタイルの配列(ポインタ)とそのサイズをとるオーバーロード
- std::arrayをとるオーバーロード
- std::vectorをとるオーバーロード
- std::initializer_listをとるオーバーロード
...
をいちいち書く作業は手間です。これを単一の引数として受け取ってしまうのがArrayProxyです。
具体的な実装はVulkan-Hppのこちらをご覧ください。前述した全て(単一要素、ポインタとそのサイズ、テンプレート引数で規定されたサイズとそのアドレス、initializer_list、data()とsize()が定義された型)を大体カバーできます。ユーザは使いたい連続データ型を引数としてそのまま渡すだけです。
これを用いることで、めんどくさい作業から解放されます。ただし、この手法の難点は、いきなり引数として謎のArrayProxyなる型が登場するため、ArrayProxyが何なのか知らないユーザが混乱してしまうことです(1敗)。そのため、コメントなり規約なりでこれが何かを丁寧に説明する必要があります。この記事に書いたのもそのためです。布教。
終わり---------------------------------------
初めて見た、という話があれば幸いです。ここに書いてある説明は雑すぎるため、各項目については是非ググっていただければと思います。
また、3D描画ライブラリやEntity Component System等について詳しい方、救いを.........
あと、C++に詳しい方、救いを........