Apple AppStoreやGoogle Playアプリストア)などのアプリケーション(ネイティブまたはWeb)を構築する場合、Model-View-Controllerアーキテクチャを使用するのが非常に一般的であることを知っています。
ただし、ゲームエンジンで一般的なComponent-Entity-Systemアーキテクチャを使用してアプリケーションを作成することも妥当ですか?
ただし、ゲームエンジンで一般的なComponent-Entity-Systemアーキテクチャを使用してアプリケーションを作成することも妥当ですか?
私には、絶対に。私はビジュアルFXで作業し、この分野のさまざまなシステム、それらのアーキテクチャ(CAD/CAMを含む)、SDKに飢えていること、そして無限に見えるアーキテクチャ上の決定の賛否両論を感じさせるあらゆる論文を研究しました。最も微妙なものでさえ、常に微妙な影響を与えるとは限りません。
VFXは「シーン」という中心的な概念が1つあり、レンダリングされた結果を表示するビューポートがあるという点で、ゲームにかなり似ています。また、アニメーションコンテキストでは、このシーンを中心に常に中心的なループ処理が頻繁に行われる傾向があります。物理コンテキストの発生、パーティクルエミッターによるパーティクルの発生、メッシュのアニメーション化とレンダリング、モーションアニメーションなどがあり、最終的にはそれらをレンダリングします。最後にユーザーにすべて。
少なくとも非常に複雑なゲームエンジンに似たもう1つの概念は、独自の軽量スクリプト(スクリプトとノード)を実行する機能など、デザイナーがシーンを柔軟に設計できる「デザイナー」の側面の必要性でした。
私は何年にもわたって、ECSが最適であることがわかりました。もちろん、それが主観性から完全に離脱することは決してありませんが、私はそれが最も少ない問題を与えるように強く見えたと思います。それは私たちがいつも苦労していたより多くの大きな問題を解決しましたが、代わりに私たちにいくつかの新しい小さな問題を与えました。
より伝統的なOOPアプローチは、実装要件ではなく、設計要件を事前にしっかりと把握している場合に非常に強力になる可能性があります。フラットな複数インターフェースアプローチでも、ネストされた階層ABCアプローチでも、設計を固定し、変更をより困難にしながら、実装をより簡単かつ安全に変更する傾向があります。単一バージョンを通過する製品には常に不安定性が必要であるため、OOPアプローチ安定性(変更の難しさと変更理由の欠如)を設計レベルに偏らせ、不安定性(変更の容易さと変更理由)を実装レベルに偏らせる傾向があります。
ただし、進化するユーザーエンド要件に対して、設計と実装の両方を頻繁に変更する必要がある場合があります。同時に植物と動物の両方である必要がある類似の生き物に対するユーザー側の強いニーズのような奇妙なものを見つけて、構築した概念モデル全体を完全に無効にする場合があります。通常のオブジェクト指向のアプローチはここではあなたを保護せず、そのような予期せぬ、概念を壊す変更をさらに困難にすることがあります。パフォーマンスが非常に重要な領域が関係する場合、設計変更の理由はさらに増大します。
複数の細かいインターフェースを組み合わせてオブジェクトの適合インターフェースを形成すると、クライアントコードを安定させるのに役立ちますが、クライアントの依存関係の数を小さくする可能性のあるサブタイプの安定には役立ちません。たとえば、システムの一部でのみ使用されている1つのインターフェースを持つことができますが、そのインターフェースを実装する1000の異なるサブタイプがあります。その場合、複雑なサブタイプ(インターフェースの責任が非常に多くあるため複雑になる)を維持することは、コードをインターフェース経由で使用するのではなく、悪夢になる可能性があります。 OOPは、複雑さをオブジェクトレベルに転送する傾向がありますが、ECSはそれをクライアント(「システム」)レベルに転送します。これは、システムが非常に少ないが準拠している全体がたくさんある場合に理想的です。 「オブジェクト」(「エンティティ」)。
クラスもデータをプライベートに所有しているため、不変条件をすべて独自に維持できます。それにもかかわらず、オブジェクトが相互に作用するときに実際に維持するのが難しい「粗い」不変条件があります。複雑なシステム全体が有効な状態になるには、個々の不変条件が適切に維持されていても、オブジェクトの複雑なグラフを考慮する必要があることがよくあります。従来のOOPスタイルのアプローチは、細かい不変量を維持するのに役立ちますが、オブジェクトがシステムの小さなファセットに焦点を当てている場合、実際には広く粗い不変量を維持することが困難になる可能性があります。
このようなレゴブロック構築ECSアプローチやバリアントが非常に役立ちます。また、通常のオブジェクトよりもシステムの設計が粗いため、システムの鳥瞰図でこれらの種類の粗い不変条件を維持することが容易になります。小さなオブジェクトの相互作用の多くは、1キロの紙をカバーする依存関係グラフを持つ小さなタスクに焦点を当てた小さなオブジェクトではなく、1つの広範なタスクに焦点を合わせた1つの大きなシステムに変わります。
それでも、私はECSについて学ぶために、ゲーム業界で自分の分野の外を見なければなりませんでしたが、私は常にデータ指向の考え方の1つでした。また、おかしなことに、私は自分自身でECSに向けて自分の道を進んで、ただ繰り返して、より良いデザインを考え出そうとしました。私はそれを完全にはしませんでした、そして、「システム」の部分の形式化である非常に重要な詳細、および生データに至るまでコンポーネントを押しつぶすことを逃しました。
ECSに落ち着くまでの経緯と、それが以前の設計の反復で発生したすべての問題を解決するまでの経緯について説明します。ここでの答えが非常に強力な「はい」である理由を正確に強調するのに役立つと思います。ECSはゲーム業界以外にも適用できる可能性があるということです。
私がVFX業界で最初に取り組んだアーキテクチャには、私が入社してからすでに10年を超える長い歴史があります。それは、総当たりの粗野なCコーディングでした(私がCを愛しているので、Cで傾斜しているわけではありませんが、ここで使用されている方法は本当に粗野でした)。ミニチュアで単純すぎるスライスは、次のような依存関係に似ています。
これは、システムのごく一部を非常に簡略化した図です。図内のこれらの各クライアント(「レンダリング」、「物理」、「モーション」)は、次のようにタイプフィールドをチェックするための「汎用」オブジェクトを取得します。
void transform(struct Object* obj, const float mat[16])
{
switch (obj->type)
{
case camera:
// cast to camera and do something with camera fields
break;
case light:
// cast to light and do something with light fields
break;
...
}
}
もちろん、これよりもかなり醜く、より複雑なコードを使用しています。多くの場合、これらの切り替えケースから追加の関数が呼び出され、繰り返し何度も切り替えを行います。この図とコードはほとんどECS-liteのように見えるかもしれませんが、エンティティコンポーネントの明確な区別はありませんでした( "is this object a camera?"ではなく、 "does this object provide =モーション? ")、および"システム "の形式化はありません(ネストされた関数の集まりがいたるところに行き渡り、責任が混同されます)。その場合、ほぼすべてが複雑であり、どんな機能も、起こるのを待つ災害の可能性でした。
ここでのテスト手順では、ここでコーディングのブルートフォースの性質(多くの場合、多くのコピーと貼り付けを伴う)が頻繁に行われるため、メッシュのようなものを他のタイプのアイテムから分離する必要があることがよくあります。それ以外の場合はまったく同じロジックがアイテムタイプ間で失敗する可能性が非常に高いです。新しいタイプのアイテムを処理するようにシステムを拡張しようとすることは、既存のタイプのアイテムを処理するためだけに非常に苦労していたので非常に困難であったため、強く表現されたユーザーエンドのニーズがあったとしても、かなり絶望的でした。
一部の長所:
いくつかの短所:
ほとんどのVFX業界は、私が収集したものからこのスタイルのアーキテクチャを使用し、設計の決定に関するドキュメントを読み、ソフトウェア開発キットをちらっと見ています。
ABIレベルでのCOMとは厳密には一致しない場合があります(これらのアーキテクチャの一部は、同じコンパイラを使用して記述されたプラグインしか持つことができません)が、コンポーネントがサポートするインターフェイスを確認するためにオブジェクトに対して行われたインターフェイスクエリと多くの同様の特性を共有します。
このようなアプローチにより、上記のtransform
関数は次のような形になります。
void transform(Object obj, const Matrix& mat)
{
// Wrapper that performs an interface query to see if the
// object implements the IMotion interface.
MotionRef motion(obj);
// If the object supported the IMotion interface:
if (motion.valid())
{
// Transform the item through the IMotion interface.
motion->transform(mat);
...
}
}
これは、古いコードベースの新しいチームが最終的にリファクタリングするために決着したアプローチです。また、柔軟性と保守性の点で元のバージョンよりも劇的に改善されましたが、次のセクションで取り上げるいくつかの問題がまだありました。
一部の長所:
いくつかの短所:
IMotion
を実装したすべてのコンポーネントは、すべての関数に対して常にまったく同じ状態とまったく同じ実装を持ちます。これを緩和するために、同じクラスに対して同じ方法で同じ方法で冗長的に実装される傾向があり、場合によっては複数の継承が背後で行われる可能性があるものについて、システム全体で基本クラスとヘルパー機能を集中化しますが、クライアントのコードは簡単でしたが、内部的には面倒です。QueryInterface
関数がほとんど常に中間から上のホットスポットとして表示され、場合によっては#1ホットスポットでさえ表示されました。これを軽減するために、コードベースキャッシュの一部をレンダリングして、IRenderable
をサポートすることが既にわかっているオブジェクトのリストをレンダリングするなどの処理を行いますが、複雑さとメンテナンスコストが大幅に増大しました。同様に、これを測定することはより困難でしたが、すべての単一のインターフェースが動的ディスパッチを必要としたときに以前に行っていたCスタイルのコーディングと比較して、いくつかの明確なスローダウンに気づきました。ブランチの予測ミスや最適化の障壁などは、コードの小さな側面以外では測定が困難ですが、ユーザーは通常、ソフトウェアの以前のバージョンと新しいバージョンを並べて比較することで、ユーザーインターフェイスの応答性などが悪化していることに気付きましたアルゴリズムの複雑さが変わらなかった領域の側、定数のみ。以前に(または少なくとも私は)問題を引き起こしていたことに気づいたことの1つは、IMotion
が100の異なるクラスによって実装されている可能性があることですが、まったく同じ実装と状態が関連付けられています。さらに、レンダリング、キーフレームモーション、物理学など、ほんの一握りのシステムでのみ使用されます。
したがって、このような場合、インターフェースへのインターフェースを使用するシステム間で3対1の関係があり、インターフェースへのインターフェースを実装するサブタイプ間で100対1の関係になる可能性があります。
その場合、複雑さと保守は、IMotion
に依存する3つのクライアントシステムではなく、100のサブタイプの実装と保守に大幅に偏ります。これにより、メンテナンスの問題はすべて、インターフェースを使用する3箇所ではなく、これらの100のサブタイプのメンテナンスに移行しました。 「間接遠心性カップリング」がほとんどまたはまったくないコードの3つの場所を更新します(依存関係のように、直接依存関係ではなく、インターフェースを介して間接的に)、大した問題ではありません。「間接遠心性カップリング」のボートロードで100のサブタイプの場所を更新します。 、かなり重要*。
*実装の観点からこの意味で「遠心性結合」の定義をねじ込むのは奇妙で間違っていることに気づきました。 100のサブタイプのインターフェースと対応する実装の両方を変更する必要がある場合に関連するメンテナンスの複雑さ。
だから私は一生懸命プッシュしなければなりませんでしたが、もう少し実用的なものにして、「純粋なインターフェース」のアイデア全体を緩和しようとすることを提案しました。 IMotion
のようなものを完全に抽象的でステートレスにすることは、さまざまな実装があることのメリットが見られない限り、私には意味がありませんでした。私たちの場合、IMotion
にさまざまな実装を含めることは、実際にはwant多様性ではなかったので、メンテナンスの悪夢になってしまいます。代わりに、クライアントの要件の変更に対して本当に優れた単一のモーション実装を作成することを目指して繰り返し、多くの場合、IMotion
のすべての実装者に同じ実装と関連付けられた状態を使用するように強制することで、純粋なインターフェイスのアイデアを回避していました。目標を複製しないでください。
したがって、インターフェースはエンティティに関連付けられた広範なBehaviors
のようになりました。 IMotion
は単にMotion
"コンポーネント"になります( "コンポーネント"の定義方法をCOMから通常の定義に近いものに変更し、 "完全な"エンティティを構成するピースにしました)。
これの代わりに:
class IMotion
{
public:
virtual ~IMotion() {}
virtual void transform(const Matrix& mat) = 0;
...
};
私たちはそれを次のようなものに進化させました:
class Motion
{
public:
void transform(const Matrix& mat)
{
...
}
...
private:
Matrix transformation;
...
};
これは依存関係の逆転原理の露骨な違反であり、アブストラクトから具象に戻ろうとしますが、私にとってこのようなレベルのアブストラクションは、合理的な疑いを超えて、将来的に真の必要性を予測できる場合にのみ役立ちます。このような柔軟性のために、ユーザーエクスペリエンスから完全に切り離されたばかげた "what if"シナリオ(おそらく、デザインの変更が必要になる可能性があります)を実行します。
そこで、私たちはこのデザインに進化し始めました。 QueryInterface
は、QueryBehavior
のようになりました。さらに、ここで継承を使用することは無意味に思われるようになりました。代わりに構成を使用しました。オブジェクトは、実行時に可用性を照会および挿入できるコンポーネントのコレクションに変わりました。
一部の長所:
Motion
実装でより簡単に対処できます。たとえば、100のサブタイプに分散する必要はありません。いくつかの短所:
発生した現象の1つは、これらの動作コンポーネントの抽象化を失ったため、それらの多くが存在することでした。たとえば、抽象的なIRenderable
コンポーネントの代わりに、具体的なMesh
またはPointSprites
コンポーネントを使用してオブジェクトをアタッチします。レンダリングシステムは、Mesh
およびPointSprites
コンポーネントをレンダリングする方法を認識し、そのようなコンポーネントを提供して描画するエンティティを見つけます。他の場合には、後から必要となるSceneLabel
などのさまざまなレンダリング可能要素があり、その場合はSceneLabel
を関連エンティティに(おそらくMesh
に加えて)アタッチします。次に、レンダリングシステムの実装を更新して、それらを提供するエンティティをレンダリングする方法を把握します。これは、変更が非常に簡単です。
この場合、コンポーネントで構成されるエンティティを別のエンティティのコンポーネントとして使用することもできます。レゴブロックを接続することで、そのように物事を構築します。
その最後のシステムは私が自分で作成した限りであり、私たちはまだCOMでそれを粗野化していました。エンティティー・コンポーネント・システムになりたいと思っていたようですが、当時は私はそれに慣れていませんでした。建築のインスピレーションを得るためにAAAゲームエンジンを検討するべきだったときに、自分の分野を飽和させるCOMスタイルの例を見回していました。ようやくそれを始めました。
私が欠けていたのは、いくつかの重要なアイデアでした。
私は最終的にその会社を去り、インディとしてECSに取り組み始めました(それでも私の貯蓄を使い果たしている間それで取り組んでいます)、それははるかに管理するのが最も簡単なシステムでした。
ECSのアプローチで気付いたのは、それでもまだ上で苦労していた問題が解決されたことです。私にとって最も重要なのは、複雑な相互作用を持つ小さな村ではなく、健全なサイズの「都市」を管理しているように感じたということです。モノリシックな「メガロポリス」のように維持することは難しくありませんでした。人口が多すぎて効果的に管理できませんでしたが、貿易ルートについて考えているだけの小さな村同士が相互作用している世界ほど混沌としていませんでした。それらの間の悪夢のようなグラフを形成しました。 ECSは、レンダリングシステムのようなかさばる「システム」に向けて、すべての複雑さを抽出しました。「人口過多のメガロポリス」ではなく、健全なサイズの「都市」です。
生データになるコンポーネントは、最初はOOPの基本的な情報非表示の原理さえも壊してしまうので、[本当におかしいと感じました。それは、カプセル化と情報の隠蔽を必要とする不変量を維持する能力であるOOPについて私が最も大切にした価値の1つに挑戦するようなものでした。しかし、そのようなロジックがインターフェースの組み合わせを実装する数百から数千のサブタイプに分散されるのではなく、わずか数十の広範なシステムがデータを変換することで何が起こっているのかがすぐに明らかになるため、問題はなくなり始めました。私は、システムがデータにアクセスする機能と実装を提供し、コンポーネントがデータを提供し、エンティティがコンポーネントを提供するという点を除いて、まだOOPスタイルの方法でそれを考える傾向があります。
広いパスでデータを変換するかさばるシステムがほんの一握りであったときに、システムによって引き起こされる副作用について推論するために、easierになりました。システムはかなり「フラット」になり、コールスタックはスレッドごとにこれまでよりも浅くなりました。私はその監督レベルでシステムについて考えることができ、奇妙な驚きに遭遇しませんでした。
同様に、これらのクエリを排除することに関して、パフォーマンスが重要な領域でさえ簡単になりました。 「システム」のアイデアが非常に形式化されたので、システムは関心のあるコンポーネントをサブスクライブし、その基準を満たすエンティティのキャッシュリストを受け取るだけで済みます。キャッシュの最適化を個別に管理する必要はなく、1つの場所に集中化されました。
一部の長所:
いくつかの短所:
ただし、ゲームエンジンで一般的なComponent-Entity-Systemアーキテクチャを使用してアプリケーションを作成することも妥当ですか?
とにかく、私は絶対に「はい」と言います。個人的なVFXの例は有力な候補です。しかし、それはまだゲームのニーズとかなり似ています。
ゲームエンジンの懸念から完全に切り離された(VFXは非常によく似ている)より離れた地域でそれを実践することはしていませんが、ECSアプローチの候補としてははるかに多くの地域があるようです。たぶん、GUIシステムでさえも適しているかもしれませんが、私はまだより多くのOOP=アプローチを使用しています(ただし、Qtのような深い継承はありません)。
それは広く未踏の領域ですが、エンティティが「特性」の豊富な組み合わせで構成できる場合はいつでも、そしてそれらが提供する特性の組み合わせが変更される可能性があるときはいつでも、そして一般化された少数の場所があるので、私には適しているようです必要な特性を持つエンティティを処理するシステム。
これは、複数の継承やコンセプトのエミュレーション(ミックスインなど)を使用して、深い継承階層または数百のコンボで数百以上のコンボを生成したくなるようなシナリオの非常に実用的な代替手段になります。特定の組み合わせのインターフェイスを実装するフラットな階層のクラスの数。ただし、システムの数は少ない(たとえば、数十)。
これらの場合、コードベースの複雑さは、タイプの組み合わせの数ではなく、システムの数に比例するように感じ始めます。これは、各タイプが、生データにすぎないコンポーネントを構成するエンティティにすぎないためです。 GUIシステムは、これらの種類の仕様に自然に適合し、他の基本タイプまたはインターフェースから何百ものウィジェットタイプを組み合わせることができますが、それらを処理するシステム(レイアウトシステム、レンダリングシステムなど)はほんの一握りです。 GUIシステムでECSを使用する場合、継承されたインターフェイスや基本クラスを持つ数百の異なるオブジェクトタイプではなく、すべての機能が少数のシステムによって提供される場合、システムの正確さを推測するのははるかに簡単です。 GUIシステムでECSが使用されている場合、ウィジェットには機能がなく、データのみがあります。ウィジェットエンティティを処理する少数のシステムのみが機能します。ウィジェットのオーバーライド可能なイベントがどのように処理されるかは私を超えていますが、これまでの私の限られた経験に基づくと、そのタイプのロジックを特定のシステムに集中的に転送できなかったケースは見つかりませんでした。後知恵は、私がこれまでに期待したよりはるかにエレガントなソリューションをもたらしました。
それは私の命の恩人だったので、もっと多くの分野で採用されるのを見てみたいです。もちろん、コンポーネントを集約するエンティティから、それらのコンポーネントを処理する大まかなシステムまで、設計がこのように機能しない場合は不適切ですが、この種のモデルに自然に適合する場合、これは私が今まで遭遇した中で最も素晴らしいものです。
ゲームエンジンのComponent-Entity-Systemアーキテクチャは、ゲームソフトウェアの性質、およびその独自の特性と品質要件のために、ゲームで機能します。たとえば、エンティティは、ゲーム内のものに対処して操作するための統一された手段を提供します。これは、目的と使用法が大幅に異なる場合がありますが、システムによって統一された方法でレンダリング、更新、またはシリアル化/逆シリアル化する必要があります。このアーキテクチャにコンポーネントモデルを組み込むことにより、コードカップリングを低く抑えながら、必要に応じて機能を追加しながら、シンプルなコア構造を維持することができます。 CADアプリケーション、A/Vコーデック、または多様な構造化パイプラインの作成を中心とするその他のシステムなど、この設計の特性から恩恵を受けることができるさまざまなソフトウェアシステムがいくつかあります。他のタイプのオブジェクトと複雑な方法で相互作用する必要があるが、開発チームが簡単に変更可能なままであるコンテンツ。
TL; DR-問題の領域が設計に課す機能と欠点に十分適している場合にのみ、設計パターンはうまく機能します。
問題の領域がそれに適している場合は確かです。
私の現在の仕事には、一連のランタイム要因に応じてさまざまな機能をサポートする必要があるアプリが含まれます。コンポーネントベースのエンティティを使用してこれらの機能をすべて分離し、拡張性とテスト容易性を分離して実現することは、私たちにとってのどかなものです。
編集:私の仕事には、(C#で)専用ハードウェアへの接続を提供することが含まれます。ハードウェアのフォームファクター、ハードウェアにインストールされているファームウェア、クライアントが購入したサービスのレベルなどに応じて、デバイスにさまざまなレベルの機能を提供する必要があります。同じインターフェイスを持ついくつかの機能でも、デバイスのバージョンに応じて実装が異なります。
ここの以前のコードベースには、実装されていない非常に幅広いインターフェースがありました。多くのシンインターフェイスがあり、1つの獣のクラスで静的に構成されたものもあります。単純にstring-> string辞書を使用してモデル化したものもあります。 (私たち全員がもっと上手くできると思っている多くの部門があります)
これらすべてに欠陥があります。広いインターフェースは、効果的に模擬/テストするのに苦労し、半分です。新しい機能を追加することは、パブリックインターフェイス(およびすべての既存の実装)を変更することを意味します。多くのシンインターフェイスは非常に醜いコードの消費につながりましたが、最終的に大きな脂肪のオブジェクトを通過するようになったので、テストはまだ苦労しました。さらに、シンインターフェイスは依存関係をうまく管理できませんでした。文字列ディクショナリには、通常の構文解析と存在の問題、およびパフォーマンス、読みやすさ、保守性の問題があります。
現在使用しているのは、ランタイム情報に基づいてコンポーネントが検出および構成される非常にスリムなエンティティです。依存関係は宣言的に行われ、コアコンポーネントフレームワークによって自動解決されます。コンポーネント自体は、依存関係を直接操作するため、独立してテストできます。依存関係の欠落に関する問題は、最初に依存関係を使用するのではなく、1つの場所で見つかります。新しい(またはテスト)コンポーネントをドロップインすることができ、既存のコードは影響を受けません。コンシューマーはエンティティにコンポーネントへのインターフェースを要求するため、さまざまな実装(および実装がランタイムデータにどのようにマップされるか)を比較的自由に自由に変更できます。
オブジェクトとそのインターフェースの構成に共通コンポーネントの(非常に多様な)サブセットを含めることができるこのような状況では、veryがうまく機能します。