himeshi’s blog

Simutrans本体改造まわりのお話をつらつらと

なぜ振り分け信号の手前で列車は減速するのか

どうも、ひめしです。コードを書く気にも本を書く気にもなれないので記事を書きます(

Simutrans standardには振り分け信号というものが用意されています。あいている適当なホームを見つけて入線する信号ですが、デフォで設定した経路以外に入線するときはきまって信号手前で減速します。これはなぜなのでしょうか?

これを理解するにはsync()とsync_step()の説明が必要です。

simutransは車両、建物、道路などといったすべてのオブジェクトに対して定義されたsync()とsync_step()という二種類の関数を延々と呼び続けることでシミュレーションを実現します。「すべての」オブジェクトかどうかはあやしいですが。鉄道車両が一番わかり易いので以降は鉄道車両を題材に説明します。

鉄道車両の場合、毎フレーム画面を描画するたびに車両の位置や速度が変化していきます。したがって毎フレームごとにこれらを再計算しなければなりません。画面を描画するたびに(正確には画面を描画する前に呼ばれる)行うシミュレーション処理は基本的にsync_step()にかかれています。convoi_tに定義されたsync_stepの冒頭は以下のような感じです。編成のstateごとに定められた処理を行っています。(コードの中身を読む必要はありません。)

sync_result convoi_t::sync_step(uint32 delta_t)
{
	// still have to wait before next action?
	wait_lock -= delta_t;
	if(wait_lock > 0) {
		return SYNC_OK;
	}
	wait_lock = 0;

	switch(state) {
		case INITIAL:
			// in depot, should not be in sync list, remove
			return SYNC_REMOVE;

		case EDIT_SCHEDULE:
		case ROUTING_1:
		case DUMMY4:
		case DUMMY5:
		case NO_ROUTE:

ところで位置の更新や速度の計算は比較的少ない計算量で終えることができますが、ルート探索などは処理が重いので1描画フレームの時間内に処理を完結させることが困難なことがあります。マップ内に10000編成いるならば、描画する間に10000編成分のsync_step()を回し終わらねばならないわけです。

そこで、「処理量は大きいけど急を要さない(1フレームの間に処理を完了させる必要がない)」処理を回す関数としてstep()が用意されています。実際、convoi_t::step()を見ると、主に

  • ルート探索
  • 停車状態での発車判定・発車処理
  • 貨客の積み下ろし

が行われていることがわかります。これらの処理はいずれも車両が停止している間に行われています。車両が停止しているならば画面が動かない限り再描画の必要はなく、位置や速度といった描画に直接関わるパラメータが変化することもないのでテキトーに処理をはじめてテキトーなタイミングで終わればよいのです。

さて、Simutransは少なくともstandardでは描画まわりはマルチスレッド化されているものの、シミュレーション部は未だ完全なシングルスレッドです。シングルスレッドのプログラムですから処理は定められた順番に逐次行われていくのですが、sync()とsync_step()はどのように呼び分けられるのでしょうか?

実際のCPU時間の使われ方を簡単な図にすると、下のようなものになります。

f:id:himeshi:20181017000428p:plain
step()とsync_step()の呼ばれかた
基本的にはsync_step()・step()両方とも、クロックを持つ外部オブジェクトから定期的に呼ばれています。もう少し具体的に言うと、呼び出し間隔が決められていて、前回の呼び出し時間から定められた時間経過すると、関数が呼び出されるチャンスが与えられるという仕組みです。
例えば描画レートが30fpsの場合、sync_step()の最後の呼び出しから1/30秒たつと(実際にはちょっと違いますが)sync_step()を呼び出すことが可能になり、sync_step()が呼ばれます。その結果図のようにsync_step()が概ね定間隔で呼ばれます。
さて、CPUがあいていればsync_step()が適切なタイミングで呼ばれますが、もし大きな実行時間を要するstep()が実行中だとsync_step()を呼び出すタイミングが遅くなってしまいます。これでは画面描画が遅くなりカクつく原因になります。そこで、step()の中では時折interrupt_check()を呼び出して、sync_step()を呼び出すことになっています。ソースコードを覗いたことがある人なら INT_CHECK という文字列に見覚えがあるのではないでしょうか?simintr.hにこのマクロは定義されており、INT_CHECKを踏むと、interrupt_check()を呼び、sync_step()を呼ぶ必要があるタイミングであるかチェックするのです。下のコードはdataobj/route.ccの一部で、INT_CHECKがコードに挿入されている例です。引数の文字列はデバッグ用途のものです。

// memory in static list ...
if(  nodes == NULL  ) {
	MAX_STEP = welt->get_settings().get_max_route_steps();
	nodes = new ANode[MAX_STEP];
}

INT_CHECK("route 347");

// we clear it here probably twice: does not hurt ...
route.clear();

このようにしてsimutransはsync_step()をできるだけ等間隔に呼びつつ、その合間に処理量の大きい処理を行っています。さて、振り分け信号の減速問題に話を戻すことにしましょう。

列車が動いているときの処理は基本的にsync_step()で行っています。列車が進めば次の閉塞に進入可能か判断する必要があります。rail_vehicle_t::is_signal_clear()がその関数ですが、この関数はsync_step()内で呼ばれます。ですので、振り分け信号を目の前にしたときの処理もsync_step()が行うことになります。
ところで、振り分け信号はまず予め指定した座標に入線できるかチェックします。もしこの時点で進入許可が出れば、列車はそのまま進行すればいいのでsync_step()内で処理は完結します。
ところがここで入線できないということになると、空きホームを探さねばなりません。空きホームを探すのは探索処理にあたり、sync_step()ではなくstep()内で行うことになっています。step()は処理の完了時間が予測できないので、列車が走行中に行う処理を記述することはできません。そこで空きホームを探す探索処理を行うために、列車を一度停止させてstep()を回す必要があるのです。
というわけで厳密には列車は一度停止しているのですが、その停止は一瞬なので見かけ上はただ減速してるように見えます。これが振り分け信号の手前で列車が減速する理由です。

なお先ほどのお話には大きく2つのツッコミどころがあります。

  • 振り分け信号の探索処理は探索空間がさほど大きくないので実はsync_step()内で行っても大丈夫なのではないか?なぜstep()内で行う必要があるのか
  • 列車が走行中に行う処理をstep()にやらせることは本当に不可能なのか?

このあたりの話はマルチスレッド化にも深く関わるところでまた大きなトピックになりますので、別の機会に解説することにしましょう。
とりあえず今回はここまでにします。お読みいただきありがとうございました。