エフアンダーバー

個人開発の記録

【Unity】 ECSへ 思考の移行ガイド

前回書いたECS記事はなかなか好評だったみたいで、ありがたいことに日本の公式にもツイートしていただきました。

www.f-sp.com

しかし一方で、反応を見ているとECSに対して否定的な意見が多く見られました。 内容から推察するに、おそらく前回記事が機能の説明に寄り過ぎるあまり、 具体的にどう使ったものかわかりづらいのが原因じゃないかと思ったので、移行ガイドを書くことにしました。 移行ガイドといっても、実際のプロダクトを移行するための手順というよりは、パラダイムシフトを促すための説明です。

はじめに

とりあえずは前回記事への反応に対する回答を。

前回記事に対する否定的な反応を大まかに分類するとこんな感じになります。

  • 複雑すぎる
  • 機能が足りない
  • 制限が多すぎる
  • 目的がわからない

一つめ、複雑すぎてよくわからないというのは多分、前回記事の物量に押された結果、全体像がよくわからなくなったパターンかと思います。 前回記事はECSのまとめなので、ECSに関するあらゆることが書いてありますが、それらについて一度に全部理解する必要はありません。 多分、旧アーキテクチャに関するあらゆることを書けばあれ以上の長さになりますし、 旧アーキテクチャを完璧に理解してコードを書いている人もそう多くはないと思います(例えば、ホットリロードが常に問題なく動作する等)。 とりあえずは基本的な部分さえ抑えておけばコードは書けるのです。 この記事の後半で、旧アーキテクチャとの対応を中心にECSの基本的な部分について軽く説明します。

二つめ、機能が足りないとか、旧アーキテクチャによる実装をECSで実装できそうにないというのは、単にUnity ECSがまだ基礎部分しかできていないためである可能性が高いです。 前回記事にも書きましたが、これからこの基礎の上にあらゆる機能を実装して初めてUnity ECSは完成します。 現時点でゲームをまるごとECSに移行しようというのは時期尚早です。

三つめ、制限が多すぎるというのは正しいですが本質的でない指摘です。 そもそも実装に制限を与える目的は、それによって保証できる性質に価値があるからです。 なので、この意見の真意は、制限の多さに対して得られる性質が見合わないんじゃないかということだと思います。 コストリターンの評価は人次第の状況次第なのでそれ自体間違いとは言えませんが、対象をよく理解して評価するのが前提です。 反応を見る限り、どうもオブジェクト指向が過大に評価されすぎていて、ECSが誤解されているような印象を受けます。 ECSの制限と価値についてはこの記事の前半に説明します。

四つめ、目的がわからない、あるいはECSを単なる高速化手法だと考えている人がいくらかいるようです。 確かに高速化はECSの大きな目的のひとつではあるのですが、 前回記事にも書いた通り、低結合性やテスト容易性といった保守性の向上もECSの利点です。 一方で、欠点は学習コストや記述量の増加になります。 このあたりはMVCをはじめとするアーキテクチャパターンにありがちな性質かなと思います。 また、DOTSの裏の目的として、Unityに積もった負の遺産の一掃があるのではないかと邪推しています。 Unityに初期からある機能は出来がイマイチで、使いにくいと感じている人は少なくないと思いますし、 公式でこうすべきではなかったと公言しているようなものもあります。 このままズルズルと引きずっていけば、いつか後発のゲームエンジンが機能面で追いついたときにシェアを奪われると危惧したのではないかと。

他にも、オブジェクト指向型言語でオブジェクト指向を捨てるのはどうなのか、といった尤もな意見もあったりしましたが、 全体的に上記のような勘違いしていそうな発言が多く目につきました。 この記事はそのあたりの誤解を解き、例え否定的であっても、ECSが正当な評価を受けることを目的としています。

この記事の前半ではプログラミング全般の思想の流行とECSとの関連について、 後半では旧アーキテクチャとECSがどう対応しているのかについて説明していきます。

オブジェクト指向からECSへ

【注意】
ここではECSが採用される理由についての『私的な見解』を述べますが、とてつもなく回り道をするため長いです。 また、思想レベルの話なので人によっては理解しがたいかもしれません。 「俺はECSの使い方さえわかればそれでいい」という人はさくっと飛ばしてください。

ECSでゲームを実装する上で多くの人がつらいと感じる一番の要因はオブジェクト指向が使えないことだと思います。 現在、世の中の大部分のプログラマはオブジェクト指向型言語でプログラミングをしていて、 あらゆる設計をオブジェクト指向を前提としてやっています。 これらのプログラマからオブジェクト指向を取り上げるのはその武器を奪うことに等しいです。 オブジェクト指向に専念して習熟した人ほど嫌がるのは当然とも言えるかもしれません。

では、何故UnityがそんなECSを採用したかといえば、 業界全体としてオブジェクト指向が徐々に衰退し始めているからだと思います。

オブジェクト指向への懐疑

オブジェクト指向型言語のコミュニティ内にいると、「オブジェクト指向こそが至高」といった雰囲気がありますが、 実はオブジェクト指向は結構批判の多いパラダイムだったりします*1

そもそもオブジェクト指向自体、定義が二つあったり、 その定義を超えたふわっとしたものとして理解されていたりとよくわからないものなのですが、 その利点もいまいちはっきりと議論されないまま、プログラマの体感をもって有用だと判断されてきたところがあります。 そんな経緯もあって、「オブジェクト指向の性質って本当に利点なの?」とか、 「オブジェクト指向の性質って本当にオブジェクト指向特有なの?」といった批判が最近活発化しています。 とりわけ有名なものを挙げると、継承による質の悪い抽象化と、状態の扱いに関する不備があります。

前者は、継承は抽象化に無関係なものを巻き込みがちで、結果として必要以上に複雑で柔軟性に欠けるクラスを生み出すという批判です。 旧いGUIシステムにありがちな超多段継承されたコンポーネントなんかを想像すると納得しやすいと思います。 ちなみに、関連したより根源的な批判として、抽象化自体が簡潔さを犠牲に限定的な保守性を確保する諸刃の剣で、 オブジェクト指向はその抽象化を必要以上に促しがちだという主張もあります。

後者は、カプセル化によって状態が隠されてしまうことによりプログラマが状態を認識しづらくなってしまうという批判や、 オブジェクト内に状態を完全に隠蔽することは難しい抽象化だという批判、 スコープを狭めるばかりで状態に対するアプローチが不十分だという批判など様々ですが、 総じて状態をもっと慎重に扱うべきだという主張が多いです。 カプセル化によって状態がなくなるわけではなく、 データとしての状態が『オブジェクトの状態』という、より論理的な状態に変わっただけだと理解すると、 いくらか納得できるのではないかと思います。 論理的な状態は論理として理解しやすい一方で、コード上やツール上で確認しづらいのです。

これらの問題点をオブジェクト指向型言語のプログラマがまったく認識していなかったかというと、実はそういうわけでもありません。 前者に関しては、「継承よりコンポジション」という言葉を知っている人も多いと思います。 継承して新たな機能を付け足すのではなく、機能を持ったオブジェクトの保有、 すなわち、機能を構成として取り込むこと(コンポジション)によってオブジェクトを拡張しようというアイデアです。 これはDI*2する上でも重要となってくる、 今日のオブジェクト指向の基本的な考えです。 後者に対しては、イミュータブル型(不変型)と呼ばれる、状態を変更できないクラス設計による対応が増えてきています。 レコード型や永続データ構造など、関連するワードを耳にする機会も多くなりました。

これらの現代的オブジェクト指向をオブジェクト指向の変化と捉えるか、設計技法の進化と捉えるか、はたまた全く別の新たなパラダイムと捉えるかは人によりますが、 なんにせよ、教科書的なオブジェクト指向はもはや通用しなくなりつつあります。 ちなみに、このオブジェクト指向の柔軟さ(?)が、批判をのらりくらりと躱しつつ、未だ最大勢力を誇っている所以のひとつだったりします。

話が逸れました、閑話休題。 とにかく何が言いたいかというと、オブジェクト指向は別に絶対的なパラダイムではないんだということです。 これを批判できるだけの別のパラダイムが存在し、書き方は違えど同程度の品質のプログラムを記述できます。 つまり、オブジェクト指向が使えないことは一概に欠点とは言い切れないのです (もちろん、「C#でそれやる?」とか「世の中オブジェクト指向プログラマばかりだし」とかは尤もですが……)。

関数型プログラミング

オブジェクト指向へ批判が集まっていることは説明しました。 では、その批判をしているのは誰なのかというと、関数型プログラミングの支持者であることが多いです。 また、イミュータブル型など、現代的オブジェクト指向の要素の原形となった機能を持つのも関数型言語であることが多いです。 ここで、簡単に関数型プログラミングおよび関数型言語について説明しておきます(正直、私もあまり詳しくはないですが……)。

オブジェクト指向プログラミングは手続き型プログラミングを元に発展してきた分野です。 オブジェクト同士のコミュニケーションというモデルではありますが、 基本的な動作は「あれをした後にこれをして……」という手続き型の考えに従っている場合がほとんどです。 言うなれば、プログラムは手順書なわけです。

一方、関数型プログラミングではすべての処理は関数です。 関数とは何かというと「いくつかの入力に対応したいくつかの出力を返すもの」です。 もちろんプログラム自体も関数で、ユーザ入力に対して出力コマンド(コンソール出力やファイル出力等)を返す関数としてモデル化されます。 直感的な手続き型あるいはオブジェクト指向に対して、非常に数学的なのが関数型の特徴です。

数学的、と言われるとなんだか難しそうに感じますが、 直感的ゆえに経験がものを言う手続き型やオブジェクト指向に対し、 数学的な関数型はモデルに従って問題を分解していくだけでプログラムができるので、見方によってはこちらの方が簡単だったりします。 手順としては、まず問題の入力と出力が何かを考えて、それを関数の入出力とします。 入力から出力を直接計算できそうにないならば、必要となる中間データを出力とするサブ問題を考えて、それを関数とします。 あとは同じことの繰り返しです。 本質的には手続き型でもオブジェクト指向でも似たようなものですが、 関数型ではデータや関数が状態を持たないことで、より自然にこれに従います。

関数型プログラミング自体の是非はさておき、 関数型の発想や仕組みはECSやDOTSを理解する上で重要となるので、もう少し詳しく見ていくことにします。

参照透過性と純粋関数

関数型言語における重要な概念として参照透過性*3があります。 これはもともと分析哲学の用語だそうで、ある言語表現が示している対象が明確であるという性質だそうです。 例えば、「日本の元号」という表現だけでは「平成」なのか「令和」なのか、はたまた元号全体を指すのかわかりませんが、 「平成が終わり、新しい日本の元号が……」と書かれれば「日本の元号」が「令和」のことだとわかります。 プログラミング言語における参照透過性とは、ある式が指す値が明確であること、 転じてある式をその計算結果で置き換えてもプログラムの振る舞いが変わらないことを言います *4。 変数の値が途中で変わったり、ログ出力のような外部に影響を与える副作用があるとこの性質は保てません。

// 式 "1 + 1" は参照透過
var x = 1 + 1;
var x = 2;

// 式 "++x" は参照透過でない
var y = ++x;
var y = 3;

// 式 "f(x)" が参照透過かどうかはfの定義次第
var z = f(x);

この参照透過性を保つように定義された関数のことを純粋関数と呼びます。 そもそも数学的な関数であればこの性質は保つはずなのですが、 プログラミング言語によっては手続きと関数を区別せずに「関数」と呼ぶため、 より数学的であるという意味で「純粋(pure)」と修飾するみたいです。 すべての関数が純粋関数でなければならない関数型言語(Haskellなど)を「純粋関数型言語」と呼び、 実用上その制限を緩めた関数型言語(Clojureなど)を「非純粋関数型言語」と呼びます。

純粋関数は参照透過性を保つ、とは具体的にはどういうことなのでしょうか。 手続き型言語の視点からもう少し具体的な性質について挙げてみると、次のようなものがあります。

  • 同じ入力に対して常に同じ出力を返す
  • 入力の値を変更しない
  • グローバルな状態あるいは外部の状態に依存しない(影響も与えない)

これは純粋関数を呼び出す際に前提としていい性質であり、視点を変えれば、純粋関数を書く際に必要となる条件です。 これらの性質は、関数を単なる入力と出力の対応表(入力から出力への写像)へと変化させます。 これにより、呼び出し側は関数内の処理について一切考慮しなくてすみます。

逆にこれらの性質を満たさない場合に何が起こるか考えてみます。 関数の出力が呼び出しごとに変化する場合、 呼び出し側はその変化が何によって引き起こされるのかを把握して、 期待する出力を得るために期待される状態をあらかじめ用意しておく必要があります。 入力の値が変更される可能性がある場合、 入力の値がどう変更されるのかを把握して、 その変更によって影響を受ける場所を確認しておく必要があります。 グローバルな状態を参照あるいは変更する場合、 どの状態を参照あるいは変更するのか把握して、 あらかじめ期待される状態にしておいたり、 呼び出し後の状態によって影響を受ける場所を確認しておく必要があります。 いずれの場合にせよ、呼び出し側は関数のシグネチャには現れない内部の処理についていくらか知っている、あるいは想像する必要があります。

純粋関数では、入力に対してどんな出力が計算されるのか、ということだけ知っていれば問題ありません。 プログラマの負担は軽減され、開発ツールやフレームワークにとっても非常に扱いやすくなるため、開発支援機能や自動の最適化が受けやすくなります。 また、保守面では、関数同士が入出力以外でやりとりしなくなるため関係が疎となり、モジュール間の疎結合が実現されます(低結合性)。 入出力の値だけでテストができるので、関数の単体テストも非常に簡単になります(テスト容易性)。

関数型における状態

オブジェクト指向に対する批判として状態の取り扱いを問題視する声があることを先に述べました。 では、そんな批判をする関数型では状態をどのように扱っているのでしょうか。 関数型における状態の扱いについて観察するため、カウンタを例に、オブジェクト指向と関数型の実装を比較してみます。

// オブジェクト指向プログラミング
class Counter
{
    public int Value { get; private set; }

    public void Inc() => this.Value++;
}

var counter = new Counter();
counter.Inc();
var value1 = counter.Value;
counter.Inc();
var value2 = counter.Value;
// 関数型プログラミング
class Counter
{
    public int Value { get; private set; }

    public static Counter Inc(Counter state)
        => new Counter { Value = state.Value + 1 };
}

var state0 = new Counter();
var state1 = Counter.Inc(state0);
var value1 = state1.Value;
var state2 = Counter.Inc(state1);
var value2 = state2.Value;

オブジェクト指向では、状態をオブジェクトの内部に閉じ込め、その値をメソッドによって書き換えることで状態遷移します。 一方、関数型では、状態と処理とを完全に分離させ、遷移前の状態を元に遷移後の状態を計算することで状態遷移していきます。

関数型の記述は一見冗長にも思えますが、状態の置き換わる範囲とタイミングが明確であるという利点があります。 オブジェクト指向では利用者が「IncによってCounterの状態が変わる」という事実を知っている必要があります。 そんなのは知っていて当たり前だと思うかもしれません。 しかし、もしCounterが内部に別のオブジェクトを持っていたらどうでしょうか。 さらにそのオブジェクトがまた別のオブジェクトを持っていたとしたら。 あるメソッド呼び出しがどの範囲のオブジェクトの状態を書き換えてしまうのか明確に把握できるでしょうか。 すべては設計者の常識と技量を信用するしかありません。 関数型では少なくとも返ってきた状態以外は変化していないことが保証されます。

この関数型の性質が最も発揮されるのが並列処理です。 書いたことのある人はわかると思いますが、 並列処理で状態を共有しようとすると、考えなければならないことが爆発的に増えます。 バグがでないようにロックしていった結果、並列化した意味がなくなったなんて話も聞きます。 CPUのコア単体の性能が頭打ちとなり、並列化が叫ばれる一方で、 並列処理を自在に操れるプログラマというのはそう多くなく、バカでも扱える並列化手法が必要とされました。 そうなると、難しい並列化と状態管理は専門家にまかせて、一般プログラマは計算部分だけ書くようにしよう、 となるのは自然な流れで、関数型の純粋関数による状態へのアプローチはこれにピタリとはまるものでした。 状態の計算と遷移の分離は、並列化の難しさをアプリケーションロジックから切り離す上で非常に都合がよいのです。

関数型とのつきあいかた

さて、ここまで散々オブジェクト指向を貶して、関数型万歳とでも言いたげな論調で書いてきました。 しかし、じゃあ関数型言語ですべて書けばいいのかというと、そういうわけでもありません。 比較的どんなコードも手軽に書ける手続き型言語やオブジェクト指向型言語に対して、 (純粋)関数型言語は得手不得手がわりとはっきりしています。 論理的な側面が強い高レイヤのコードを書くのは得意なのですが、 手動での最適化が必要な低レイヤのコードを書くには限界がある場合が多いのです。 これは関数型言語が、良くも悪くも最適化の大部分を言語のコンパイラやランタイムに頼っているためです。

そんな理由もあってか、関数型プログラミングが流行していると言われる一方で、実は関数型言語はそれほど流行していなかったりします *5。 対処法として、非純粋関数型言語を利用するというのもひとつの手ですが、 実際には手続き型言語やオブジェクト指向型言語で関数型プログラミングをすることが多いようです。 言語によるサポートを受けられないため多少の書きづらさはありますが、 必要に応じて手続き型の書き方に切り替えられるため、柔軟な対応ができます。

初めのうちはオブジェクト指向に関数型プログラミングを取り込むという、 先に述べた現代的オブジェクト指向やその延長が主でしたが、 関数型プログラミング主体で、パフォーマンスが欲しい部分だけ手続き型で書く形式も増えてきたように感じます。

データ指向とECS

ここまでが事前知識で、ようやくECSの話に戻ります。

ECSを含むDOTS(Data-Oriented Technology Stack)は名前の通り、データ指向設計に基づいた技術スタックです。 データ指向設計とは、データのメモリレイアウトや読み書きされるタイミングとその変換に注目した設計法だそうです *6。 私見ですが、DOTSの内容を見ていると、Unityはもう少し拡大解釈して、「データを中心とした設計」くらいに捉えている気がします。 あるいは、上記の定義に従って設計していくと、自然とそうなるのかもしれません。

データ指向設計では、同時に使われる可能性の高いデータがメモリ上で近くなるように配置することで、 空間的局所性に基づいたCPUのキャッシュヒット率を高め、処理を高速化します。 ここで押さえておくべきは、データのメモリレイアウトを優先する都合上、 「時期」よりも「意味」でデータをまとめがちなオブジェクト指向との相性がよくないことです。 一方で、データとその変換を組み合わせてプログラミングするスタイルは、まさしく関数型のそれで、関数型プログラミングとの相性は悪くないです。

ECSはこのデータ指向設計に基づいたソフトウェアアーキテクチャパターンです。 ソフトウェアアーキテクチャパターンというのはアプリ規模のデザインパターンのようなものです。 ECSではエンティティ、コンポーネント、システムの大きく三つの要素を組み合わせることでゲームを構成します。 エンティティは処理単位、コンポーネントはその属性、システムはそれらの変換処理を表します。 エンティティとコンポーネントがデータ、視点を変えればゲームの状態に当たり、 システム全体は前フレームの状態から次フレームの状態を計算する関数になります。

ECSがデータ指向設計に基づいていると言われるのは、コンポーネントの記憶と処理の方法に言及があるためです。 ECSでは基本的にコンポーネントを型ごとの配列として記憶しておき、コンポーネントの型によるクエリで順次処理していきます。 配列として記憶することで同じ型のコンポーネント同士がメモリ上で連続するようにし、 コンポーネントの型によって処理対象を選択することで同じ型のコンポーネントに同時期にアクセスする仕組みになっています。 近いメモリに同時期にアクセスことになるため、高いキャッシュヒット率が実現されます。

ECSの利点

ECSの利点としては次のようなものが挙げられます。

  • 高キャッシュ効率
  • 並列性
  • 低結合性
  • テスト容易性

キャッシュ効率については前述したとおりですが、その他の性質はいったいどこから来るのでしょうか。 これを解明するために、ECSの"S"、システムについてもう少し詳しく考えてみます。

システムは変換処理を担当するわけですが、これは次の三つの処理に分解できます。

  1. 入力コンポーネントの取得
  2. 入力値から出力値の計算
  3. 出力コンポーネントの更新

厳密には、エンティティやコンポーネントの操作(生成や破棄など)が可能なのでこの通りではありませんが、 これらの操作もコマンドとして出力の内と考えればだいたいこんな感じになります。 ECSにおいてシステムは状態を持たないため、(おかしなことをしなければ)計算部分は概ね純粋関数とみなせます。 これを前提として各利点について検討していきます。

まず並列性ですが、純粋関数による計算が並列化しやすいのは先に述べた通りです。 勝手に状態を共有しないため、データ競合が起きる心配がありません。 加えて、システムは入出力の対象について事前にクエリで指定するため、 フレームワーク側で競合が発生しないように制御してやれば、システムをまたいだ並列化も可能です。

低結合性は、システム同士が直接干渉しないことに起因しています。 純粋関数は入出力以外でやりとりをしないため、関係が疎だと言いました。 システムもその性質を受け継いでいて、エンティティとコンポーネント以外でやりとりをしないため、 システム間は疎結合が保たれます。

テスト容易性は、システムが状態を持たないことにより達成されます。 純粋関数と同様に、状態を持たないならば入出力の値だけでテストができます。 つまり、対象コンポーネントを持ったエンティティを生成してシステムを実行し、 結果のエンティティを確認するだけで済みます。 場合によっては、内部の純粋関数をテストするだけでも十分かもしれません。

総じて、システムが純粋関数的であるために導かれているのがわかると思います。 つまり、ECSの利点の多くは関数型プログラミングから取り入れられているのです。 そしてもちろん、ECSの制限も、です。 これを意識しておくと、ECSでの設計に迷ったときのいい指針となります。 何故なら、こうした制限への対処法を関数型プログラミングの手法に求めることができるからです。

ここまでのまとめ

ざっくりと。

  • オブジェクト指向はさほど重要ではない
  • 関数型プログラミングには良い性質がいくつかある
  • ECSは関数型の性質をうまく取り込んでいる

始めにも書きましたが、ここで述べたことはあくまでも私論です。 正しいか否かは自身でよく考えて判断してください。

旧アーキテクチャからECSへ

前回記事にてECSの使い方についてはすべて説明しましたが、 具体的に記述をどう変えていけばいいかという話はしませんでした。 そこで、ここでは補足として、旧アーキテクチャの主要な記述をECSでどのように表現するかについて少しだけ説明します。 前回記事を読んでいる前提で書くため、完全な理解まではできずとも、一通り目を通してから読むようにしてください。

注意点として、ECSにはまだ十分なエディタ機能がないため、 「旧アーキテクチャでは『エディタで』あーすればできた機能」に対応する機能は基本的にまだありませんし、ここでは扱いません。 ここで説明するのは、コード記述同士の対応のみです。

アーキテクチャ概要

一応、全体像を簡単に確認しておきます。

旧アーキテクチャは、GameObjectComponentの二つの要素から成ります。 Componentには、組み込みのTransformCameraなどに加えて、MonoBehaviourを継承したユーザスクリプトが含まれます。 旧アーキテクチャは、GameObjectを処理単位として整理された各Componentが、メッセージ(Update等)に反応して、 自身や関連Componentの状態を更新していくモデルでした。 Componentという部品同士がコミュニケーションをとるオブジェクト指向的な側面があります。

ECSは、エンティティとコンポーネントとシステムの三つの要素から成ります。 エンティティを処理単位としてコンポーネントを整理し、 毎フレーム実行されるシステムによって、対応するコンポーネントの状態を更新していくモデルになっています。 エンティティとコンポーネントがデータ、システムが処理、と明確にデータと処理を分離させ、 データの変換によって目的を達成しようとする関数型的な側面があります。

Component」は旧アーキテクチャの要素を、「コンポーネント」はECSの要素を指すものとして記述するので、 混同しないように注意してください。

GameObject

GameObjectは複数のComponentを保持する役割を持ったオブジェクトでした。 これに対応するECSの要素は簡単で、エンティティです。 どちらも、それ自体にはあまり機能はなく、Componentやコンポーネントを処理対象ごとにまとめるために存在しています。 概ね、GameObject ≒ エンティティ、と考えて問題ありません。

とはいえ、一応細かい部分もいくらか詰めておきましょう。 GameObjectとエンティティの違いを挙げるならば、上記のまとめる機能以外を有するか否かです。 GameObjectにはありますが、エンティティにはありません。

例えば、GameObjectには名前(name)やタグ(tag) 、レイヤー(layer)などがありますが、エンティティにはありません。 これらの機能が必要ならば、コンポーネントとして作成してエンティティに関連付けることになります。 アクティブか否か(activeSelf)は、無効を表すコンポーネント(Disabled)が用意されていて、 これが関連付けられているか否かで判断します。 さらには、プレハブすらも対応コンポーネント(Prefab)の関連付けで表現します。 あらゆる機能がコンポーネントとそれを処理するシステムに追い出された形になります。

また、エンティティは階層構造を前提とはしていないため、 GameObjectのようにTransformを勝手に生成したりはしません。 必要ならば、組み込みの階層構造用コンポーネント(Parent)や、 モデル変換用コンポーネント(LocalToWorld等)を関連付けて使います。

エンティティが何故ここまで徹底して機能を持たないかというと、 エンティティの生成負荷を限りなく小さくするためです。 GameObjectは生成がそれなりに重く、大量にインスタンス化すると結構な負荷がかかるのですが、 エンティティは大量に生成しても必要分以上の負荷はかかりません。

Component

ComponentGameObjectに特定の機能を与えるオブジェクトでした。 ECSでは、これに対応する機能をコンポーネントとシステムの組み合わせによって実現します。 言い換えれば、あるComponentの機能をECSへと変換するためには、コンポーネントとシステムに分解する必要があります。 ComponentTransformCameraなどの組み込みのものを含みますが、 スクリプトにおいてはMonoBehaviourを指すため、以降はMonoBehaviourに限定して説明していきます。

分解の基本的な方針は非常に簡単です。 スクリプト内のデータ部分をコンポーネントに、処理部分をシステムに分けるだけです。 小さなスクリプトであれば、すべてのフィールドを含むコンポーネントと、すべてのメソッドを含むシステムを定義して、 システム内にそのコンポーネントを取得するクエリを書けば、それで完了します。

// 旧アーキテクチャ
public class Counter : MonoBehaviour
{
    public int Value;

    private void Update() => this.Value++;
}

var go = new GameObject();
go.AddComponent<Counter>();
// ECS
public struct Counter : IComponentData
{
    public int Value;
}

public class CounterSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((ref Counter counter) => counter.Value++);
    }
}

var entity = EntityManager.CreateEntity();
EntityManager.AddComponentData(entity, new Counter());

とはいえ、ここまで簡単なスクリプトというのは現実的ではありません。 しかし、複雑なスクリプトをこれと同じ方法でECS化してしまうと、単にわかりづらくなるだけで何の益もありません。 あるいは、そもそも動かなかったりします。

そこで、もう少し大きなスクリプトの分解方法について詳しく見ていくことにします。 オブジェクト指向から関数型へのパラダイムシフトを体現している部分が多いので、 その辺りを意識しながら読むとわかりやすいかもしれません。

データの分解

大きなスクリプトではクラス内に多くのフィールドが定義されていることがしばしばあります。

public class MyScript : MonoBehaviour
{
    public int Foo;
    public float Bar;
    public Vector3 Baz;
    // ..., and so on.
}

これをそのままコンポーネントにしてしまうと、巨大な構造体となってコピーコストが嵩むうえに、 コンポーネントの配列をメモリ上に確保した際に同じフィールド間の距離が離れてしまうため、キャッシュ効率も悪くなります。 そのため、ECSではなるべく小さな単位でデータ化することが推奨されます。

ではどの程度に切り分ければいいのかということですが、 同時に使われることの多いフィールドのみを同じコンポーネントとする、というのが基本方針になります。 例えば、三次元ベクトルはX要素、Y要素、Z要素から成りますが、これらの要素単体で何らかの処理を行うことはあまりありません。 一方で、位置と速度というのは、関連のある要素ではありますが、位置だけを参照して何かをすることがよくあります。 そのため、三次元ベクトルの各要素は同じコンポーネントとすべきですが、位置と速度は別々のコンポーネントにすべきです。 とはいえ、明確なルールはありませんし、文脈次第なところもあるため、 よくわからなければ元スクリプトのフィールド単位でコンポーネント化してしまうのもひとつの手だと思います。

public struct Foo : IComponentData
{
    public int Value;
}

public struct Bar : IComponentData
{
    public float Value;
}

public struct Baz : IComponentData
{
    public Vector3 Value;
}

// Foo と Bar が同時に使われるケースが多い場合
public struct FooBar : IComponentData
{
    public int Foo;
    public float Bar;
}

ちなみに、旧アーキテクチャでは1ファイル1スクリプトという制限がありましたが、 ECSにはそんな制限はないため、ひとつのファイル内にいくつコンポーネントを書いても構いません。 というか、コンポーネントごとにファイルを作るとファイルで溢れて読みづらいので、ある程度まとめることをおすすめします。

処理の整理

旧アーキテクチャはオブジェクト指向的なので、フィールドがクラス内のいたるところで参照されている可能性があります。

public class MyScript : MonoBehaviour
{
    // ...

    private void Update()
    {
        // the answer to life, the universe and everything.
        SinCos();
        NtNgNtNg();
        ShOrShOr();
    }

    private void SinCos() => this.Bar = Mathf.Sin(Mathf.Cos(this.Baz.magnitude));
    private void NtNgNtNg() => this.Foo = -~-~(this.Bar == this.Bar ? (int)this.Bar : default(int));
    private void ShOrShOr() => this.Foo |= (this.Foo | this.Foo << this.Foo) << this.Foo;
}

しかし、ECSのシステムでは、これらはコンポーネントとして外部から与えられるためthisを介したアクセスはできません。 メソッドの引数として扱えるようにしておく必要があります。 そこで、Updateを次のような形式に変形します。

private void Update()
{
    this.Field1 = PureFunction1(this.FieldA, this.FieldB, /* ... */);
    this.Field2 = PureFunction2(this.FieldC, this.FieldD, /* ... */);
    // ...
}

ここで、Update内で呼び出しているメソッドは純粋関数である必要があります。 記事の前半を読み飛ばした人のために簡単に説明すると、純粋関数とは引数『のみ』を利用して戻り値の計算『のみ』をする副作用のない関数(メソッド)です。 ルールベースで理解するならば、(直接・間接問わず)静的フィールドにアクセスせず、かつ引数の値を書き換えない静的メソッドと覚えてもいいかもしれません。 純粋関数による変形によって、スクリプト内のthisアクセスがUpdate内のみになります。

public class MyScript : MonoBehaviour
{
    // ...

    private void Update()
    {
        // the answer to life, the universe and everything.
        this.Bar = SinCos(this.Baz);
        this.Foo = Answer(this.Bar);
    }

    private static float SinCos(Vector3 v) => Mathf.Sin(Mathf.Cos(v.magnitude));
    private static int NtNgNtNg(float x) => -~-~(x == x ? (int)x : default(int));
    private static int ShOrShOr(int x) => x | (x | x << x) << x;
    private static int Answer(float x) => ShOrShOr(NtNgNtNg(x));
}

純粋関数によるコーディングにはいくらか慣れが必要ですが、 オブジェクト指向あるいはC#のメソッドがどのように実現されているのかを理解して、 メソッドの入出力は何なのか始めに確認しておくとやりやすいと思います。 ヒントになりそうな事柄をいくつか列挙しておきます。

  • インスタンスメソッドは暗黙の引数としてオブジェクト自身(this)を受け取っている
    • オブジェクトの状態と考えても可
    • IL(中間言語)では実際に引数同様スタックに積んでいる
  • すべてのメソッドは暗黙の引数としてグローバル変数(静的フィールド)全体の集合を受け取っている
    • プログラム全体の状態と考えても可
    • こちらは単に純粋関数として考えるための理論上の話
  • 引数オブジェクトの書き換えは、書き換え後と同じ状態のオブジェクトを生成して返すのとほぼ等価
    • ただし、オブジェクトを共有している場合には注意

ECS化

ここまでの分解がうまくできていれば、概ね次のような形式になっているはずです。

// DOTSでは UnityEngine.Vector3 より Unity.Mathmatics.float3 が好ましいため置き換え
// Unity.Mathmatics はシェーダ風APIの数学ライブラリ
// Unity.Mathmatics の演算(sin, cos, length)は次のようにインポート可能
using static Unity.Mathematics.math;

public class MyScript : MonoBehaviour
{
    // コンポーネント一覧
    public Foo Foo;
    public Bar Bar;
    public Baz Baz;

    private void Update()
    {
        // 純粋関数による計算手順
        this.Bar.Value = SinCos(this.Baz.Value);
        this.Foo.Value = Answer(this.Bar.Value);
    }

    // 純粋関数一覧
    private static float SinCos(float3 v) => sin(cos(length(v)));
    private static int NtNgNtNg(float x) => -~-~(x == x ? (int)x : default(int));
    private static int ShOrShOr(int x) => x | (x | x << x) << x;
    private static int Answer(float x) => ShOrShOr(NtNgNtNg(x));
}

public struct Foo : IComponentData { public int Value; }
public struct Bar : IComponentData { public float Value; }
public struct Baz : IComponentData { public float3 Value; }

コンポーネントは既に定義済みなため、あとはシステムを定義すればECSで同等の処理が実現できます。

システムを定義するには、Updateの各行に注目します。 Updateの行は入力コンポーネントの値から出力コンポーネントを計算する形になっているため、 その入出力をそのままクエリにしてしまいます。 あとは必要な純粋関数もそのまま一緒に定義してやればシステムの完成です。

public class SinCosSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((ref Bar bar, ref Baz baz) => bar.Value = SinCos(baz.Value));
    }

    private static float SinCos(float3 v) => sin(cos(length(v)));
}

[UpdateAfter(typeof(SinCosSystem))]
public class AnswerSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((ref Foo foo, ref Bar bar) => foo.Value = Answer(bar.Value));
    }

    private static int NtNgNtNg(float x) => -~-~(x == x ? (int)x : default(int));
    private static int ShOrShOr(int x) => x | (x | x << x) << x;
    private static int Answer(float x) => ShOrShOr(NtNgNtNg(x));
}

システム間に実行順序がある場合には、忘れずにUpdateBefore属性またはUpdateAfter属性で順序を指定します。 あるいは、システムを分ける必要性を感じなければ、ひとつのシステムに複数のクエリを書いても構いません。

public class MyScriptSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((ref Bar bar, ref Baz baz) => bar.Value = SinCos(baz.Value));
        Entities.ForEach((ref Foo foo, ref Bar bar) => foo.Value = Answer(bar.Value));
    }

    // ...
}

現実問題と基礎

おそらくここまで読んで多くの人が抱く感想は、「そんなにうまくいくものだろうか?」といった感じだと思います。 はっきり言ってしまうと、『そんなにうまくはいきません』。 というか、ほとんどのケースでこの通りにはいきません。

ここで扱ったスクリプトは単に計算のみを行うものでした。 しかし現実には、別のGameObjectComponentを操作したり、GameObjectを生成あるいは破棄したりといった処理が含まれることがほとんどです。 この場合、ECS側でも、クエリをネストしたり、エンティティの生成や破棄の操作を入れたりして対応しなければなりません。 こうなると純粋関数だけではどうにもならないため、部分的に純粋性を壊したりといった対処が必要になってきます。

しかし、どのような場合においても、この変換におけるそれぞれのステップは依然として重要です。 スクリプトの持っていたデータをどうコンポーネント化していくかや、 Updateの内容をどうシステムに落としていくか、といった部分の基本的な指針は常に変わりません。 適切な大きさにデータを切り分け、入出力を意識して処理を整理していく流れさえ身につけてしまえば、応用はある程度利くものです。

Tips

ECSの実装を考える上でヒントになりそうな事柄についていろいろと書いておきます。 参考程度にどうぞ。

命令型と宣言型

この記事で主に説明したECSへの変換方法は単純な計算を対象としたものでした。 単純な計算のみで実行できる内容なんてたかが知れている、と思うかもしれませんが、想像よりその範囲は広いかもしれません。 というのも、ECS上の各機能は宣言型のAPIによって実装されることになるためです。

APIには「命令型(imperative)」と「宣言型(declarative)」の二つのタイプがあります。 命令型は「○○せよ」という類のもので、宣言型は「○○であれ」という類のものです *7。 旧アーキテクチャのTransformを例にとれば、 Rotateは「回転せよ」という命令型のAPIで、rotationは「その回転量であれ」という宣言型のAPIです。 命令型が具体的な操作を手続き的に実行するのに対し、 宣言型は得たい動作や出力の性質の記述のみを行うことに特徴があります。 それゆえに、命令型が関数呼び出しとなりがちなのに対し、宣言型はデータの設定による形式をとることができます。

ECSでは処理はシステムに記述され、システム同士はデータによってやりとりします。 そのため、何か別のシステムに処理を委譲したい場合、自然とデータを介した宣言型のAPIになります *8。 これは、必要な性質を記述したデータの計算さえできれば大抵の処理はできる、ということに他なりません。

また、自分がシステムを設計する際には宣言型を意識しておくと、発想しやすく、あとも楽かもしれません。

メモ化

ECSでは純粋関数によって処理を整理することが多くなりますが、 純粋関数は状態を持たないという性質上、 呼び出しごとに全体の再計算が必要となり、パフォーマンスに影響が生じる場合があります。 このような場合、『メモ化(memoization)』というテクニックで、純粋性を犠牲にすることなくパフォーマンスを改善できる場合があります。

メモ化は簡単に言えば、計算結果のキャッシュです。 純粋関数は入力に対して出力が一意に決まるという性質があるため、 計算した際に入力と出力を記憶しておけば、 次回呼び出し時に入力をチェックするだけで、一度した計算を全て省略して出力を得ることができます。

キャッシュする組み合わせの数については、どれくらい入力がばらけるかどうか推測した上で決定します。 フレーム毎の変化がほとんどないのであれば、直前の計算結果のみでも十分に効果があります。 逆に、ばらけすぎていてキャッシュが大きくなりすぎるようであれば、 それは元々キャッシュすべき問題でないか、キャッシュする範囲が適切でないかのいずれかです。

関数型プログラミングをしていると、 たびたび純粋性を壊さなければうまくいかないように思えるケースに出くわしますが、 そういう場合にメモ化が救世主となることは多いです。 単純な仕組みですが覚えておくと便利です。

E-C-S以外の型

ECSという名前から、これら三要素以外の型をつくってはいけないような気がするかもしれませんが、そんなことはありません。 正確には、いずれかの要素の一部として、IComponentDataComponentSystemなどを実装しない型を定義しても構いません。

例えば、コンポーネントの一部としてIComponentDataISharedComponentDataに含まれる構造体やクラスを定義することができます。 コンポーネント化しづらい複雑なデータ構造をクラスとして定義して、ISharedComponentDataで保持するといったこともできます。 ただし、コンポーネント内に構造を作りこむ前に、細かいコンポーネントに分解できないかどうかは検討すべきです。

また、システムにおいては、複数のシステムに共通する処理を別のクラスに切り分けて整理しておくと便利です。 このとき、共通処理はシステム用なので状態を持たず、切り分けたクラスは純粋関数を集めた静的クラスになります。 オブジェクト指向ではこの手のクラスはユーティリティクラスとして嫌われることがありますが、 関数型ではそれ自体は普通のことで、内容に一定のまとまりがあるならば何の問題もありません。

おわりに

前回は淡々とECSの機能を紹介したので、今回はもう少し思想的な部分を説明しました。 ただこの辺りの話は公式ではあまり触れてくれないため、正しいかどうかの裏取りができず、 いろいろツッコまれるのではないかと内心ひやひやしています。 自分の勝手な解釈かもしれない部分も結構多いので、マサカリはいくらか覚悟しています。

後半はもっと具体的な変換パターンをいろいろ紹介してわかりやすくするつもりだったんですが、そこに辿り着く前に力尽きました。 結果的に、「実際どう書けばいいの?」に対するアンサーとして不十分で、「やっぱりECS難しいじゃん」と言われそうな気がしてます。 ECSの普及に努めようとして墓穴掘った感……。



執筆時のUnityのバージョン:2019.1.4f1
執筆時のECSのバージョン:0.0.12-preview.33

*1:有名税という見方もできますが

*2:Dependency Injection: 「依存性注入」という訳が定着しているが意味的には「依存(対象)注入」

*3:Referential Transparency: 「参照透過性」という訳が定着しているが意味的には「参照(対象)明瞭性」

*4:非常に紛らわしいですが、言葉の経緯からわかるように、参照透過性の『参照』はポインタを抽象化した概念のことではありません

*5:まあ「新しい言語覚えるのめんどい」とか「関数型使いづらそう」とかの方が理由としてでかそうですが……

*6:各所の説明を読んでいると、正直『データ』というより『ハードウェア』や『アーキテクチャ』とでも呼んだ方がいい気がする

*7:宣言型の定義はいくつかあって、副作用のないこと(純粋関数)を指す場合もあります

*8:データがあれば処理するという形式をAPIと呼ぶかどうかは謎ですが……