レンダリングエンジン設計(1)

レンダリングエンジンの設計(1)
ここでは、実装に入る前に、レンダリングエンジンの全体像・目指す姿を設計していきます。
ゲームにおけるレンダリングとは
ゲームの世界は時間とともに、移動するキャラクター(以後、アクター)の座標が更新されていきます。
まぁ誤解を恐れずに言うとそれだけです。
もちろん、それらが相互干渉することでゲームらしさがでてくるのですが。
なので、その世界においてレンダリングはあくまで私たち人間が視覚的に認知するための、ある時間tにおける一瞬をスナップショットしたものを可視化したものとみなすことができます。
現実の世界は、時間とともに連続的に変化しています。
物体の位置、速度、姿勢、力のかかり方は、本来であれば滑らかに変化していくものです。
しかし、コンピューター上でゲーム世界を扱う場合、その連続的な変化を完全にそのまま表現することはできません。
コンピューターは、ある瞬間の状態を計算し、その結果を次の状態として保存する、という離散的な処理を行うからです。
そのためゲームでは、世界の時間を小さな単位に分割し、一定または可変の時間幅ごとに状態を更新します。
これが、いわゆるステップ更新です。
たとえば、あるアクターが速度を持って移動しているなら、ゲームは「前回の位置」と「経過時間」と「速度」から、次の位置を計算します。物理演算であれば、力、加速度、速度、衝突判定などを順番に評価し、次の世界状態を決定します。
つまりゲーム世界は、概念的には連続的な世界を扱っているように見えても、実装上は次のような状態の列として表現されます。
Δtに関して、一定または可変の時間幅と書きましたが、世界の更新は固定の時間間隔を利用するのがよりシンプルかつ、マシンのスペック差による不平等も発生しないため、本エンジンでは固定のΔtを利用します。
正確に言うと、ここはエンジン利用者が選択できる自由度を残したいと思いますが、このΔtの間隔と、実際の時間tとなるべく近づける処理等が必要になるため、そこら辺の支援も含めたいと思います。
Δtはゲーム内容によって 1/30, 1/60, 1/120 sec あたりが選択されるのだと思います
ここで重要なのは、ゲームの本質的な状態更新と、画面へのレンダリングは必ずしも同じ間隔で行う必要がない、という点です。
レンダリングとは、ある時刻におけるゲーム世界の状態を、人間が視覚的に認知できる形に変換する処理です。言い換えると、レンダリングは世界そのものではなく、世界のある瞬間を切り取ったスナップショットの可視化です。 ゲーム世界の更新は、ゲーム内のルールや物理、入力処理、AI、当たり判定などを正しく進めるために行われます。一方でレンダリングは、その結果を人間に見せるために行われます。両者は目的が違います。
そのため、世界の更新間隔と、画面の描画間隔は同期している必要がありませんし、互いに影響を与えないのが理想です。
たとえば、ゲームロジックや物理演算を 1/60 秒ごとに更新しつつ、描画はモニターのリフレッシュレートに合わせて 1/144 秒ごとにしても問題ありません。。逆に、処理負荷が高い場面では、描画フレーム数が落ちても、ゲームロジックは一定の間隔で進めたい場合があります。
このエンジンにおいても、これは基本原則として設計していきます。
理想と現実
ゲームの世界の更新と、レンダリングは互いに影響を与えない。と言いましたが、現実はそううまくはいかず、世界の状態をみてレンダリングする必要があるため、レンダリング中は状態を変更することはできません。
なので、スナップショットという言葉のイメージからは程遠く、何も考えないで実装するとレンダリング中は、世界の状態更新が止まります。
この状態では例えばレンダリングで100msかかるとすると、世界も100ms止まることになります。とりあえず、このままでもゲームを成り立たせることはできます。世界の更新を長期的に既定の間隔で更新できるように、レンダリング自体をスキップすればよいからです。
例えば世界更新を 1/60 秒、つまり約 16.6ms ごとに行うとします。
一方で描画に 16.6ms より長い時間がかかる場合、毎回描画してしまうと世界更新が遅れていきます。
この単純実装では、遅れている間は描画を捨て、未消化の世界更新を優先して消化します。
重要なのは、スキップするのは世界更新ではなく描画という点です。
世界は 16.6ms 刻みで進め続け、画面に出すスナップショットだけを間引きます。
上記の図の通り、Δtは固定のため、局所的にみると理想的な状態更新タイミングと、実際の状態更新タイミングはずれていますが、長期的視点で見ると辻褄が合うため、問題はありません。
が、それぞれが同期的に直列で処理されているので、お互いを待っている時間が無駄です。
冒頭に記載した、世界更新とレンダリングの分離は到底実現できていませんので、対策を検討していきます。
レンダリング処理の分離
ということで、レンダリング処理を分離する方法を考えます。
これは素直に直列に処理されている部分を並列にするしかないのでは?ということですね。
ここでスレッドという概念を使用します。
スレッドはプロセス内部で実際に処理を動かす最小単位であり、スレッドの数だけ並列に処理を実行できます。
要するに、以下の二つを別のスレッドに分離してしまえばいいという事になります。
- 世界を更新するスレッド
- レンダリングするスレッド
分離した結果、レンダリングのお願いだけを行うこととなり、実際のレンダリングについてはいつ実施されるかどうかは管理対象外となります。
お願いしたんだから、いつかレンダリングされるでしょ。という精神です。そもそも世界を保つスレッドは、レンダリングされることなんてなんら重要ではありません。そんなことどうでもよいのです。
このように適切に責務を分離することが重要です。
結果的に、先ほどの図は、下記のようになります。
状態更新が理想的な時間と一致していること、及び、描画できるフレームの数が圧倒的に多い事がわかるはずです。
前述の初期化関数では、まずレンダリングスレッドの起動を行う必要がありそうです。
世界を更新するスレッドは基本的にメインスレッドにて実装することになると思います。が、必要に応じて並列処理も可能です。
これについては、どこまでエンジンが担当するかのデザインにもよるので、後々考えることにしましょう。
さて、上記の図のような理想的な状態に簡単にできるのかというと、全くそんなことはありません。
レンダリングには、世界の状態が必要なので、このままでは並列化したところで、レンダリング中は世界の状態を変更することはできません。
要するに、何も変わっていません…無理やりやると、レンダリングしている最中に状態が変わるため、おかしな不具合が発生します。
要するに、世界をスナップショットできていないのです。
レンダリング処理を独立させるには、世界の状態のコピー=スナップショットが必要になります。
では、このスナップショットをどうデザインするか?というのが次のテーマです。
スナップショット
世界のスナップショットをどのように取得するか。
単純に考えると、全オブジェクトの状態のコピーを作ればいいじゃない。ということですが、全オブジェクトをレンダリングするわけではないので、それでは少し無駄ですし、寿命管理の問題もありますよね。
ここで、レンダリングエンジンのAPI体系がどのようになるか含めて検討します。
単純に2Dの線を引く場合について考えます。
APIとしては、以下のようになると思います。(実際には、太さや色とかもう少し必要ですが)
void OVLIT_DrawLine(const ovlit_vector2 *p0, const ovlit_vector2 *p1) OVLIT_NOEXCEPT;ここで状態とはp0,p1の事です。これらは世界の状態そのものであり、描画完了まで変わってはいけません。
ですので、レンダリングエンジンとしてはこのp0,p1をスナップショットすればよいのです。
この関数の処理は以下のようになります。
- 2D線を引く意図と、p0,p1の変数をコピーして、内部コマンドとして格納
レンダリングスレッドは別にしたので、ここで実際に描画までは行わないことが重要です。
レンダリングスレッドは、この内部コマンドの一覧をたどりながら、別途レンダリングを行います。
これにより、以下の特性が実現できます。
- 呼び出し元にすぐに制御が返せるため、世界の中断が最低限で済みます
- 状態は既にコピーされているため、この関数から制御が戻れば、もう変更して問題ない
レンダリングエンジンとしては、特性(1)の中断時間をいかに減らせるかがポイントになります。
というのも、レンダリング時におけるAPI呼び出し回数は、非常に多くなるからです。
では、どうやって中断時間を減らすかについて、検討していきます。