Godot(C#)でTrail Rendererを作る

## 要約
　「ImmediateMeshを使って軌跡(Trail Renderer)を描こう」の方式説明とサンプルコード
 　滾る(たぎる)漢のトレイルレンダラー

## はじめに
　トレイルレンダラー。それは弾の軌跡を描くシューティングゲームの花形。これが無くてもゲームは成立しますがあればとても嬉しいものです。コンピューターの計算資源を生贄に、滾る漢の表現を手に入れましょう。

　英語情報を調べればやり方は出てくるのですが、日本語の記事が無いので紹介します。元々は3Dトレイルでしたが私が欲しいのは2Dトレイルなので最後に紹介するコードは2D用になってます。

 環境：Godot4.1＋ C#(.Net Framework6.0)
 
## 仕組み
　移動する弾のPositionを一定時間分、毎フレーム記録します。具体的には60fpsで2秒なら120の座標情報をリストに保持します。
 次に座標情報と新旧に応じて塗りつぶす領域の範囲を小さくすることで、尾を引くような描画を行います。
 　図にするとこんな感じです。

<img width="800" alt="Trail Rendererの動作" src="https://pbs.twimg.com/media/F_GqhsNbEAAPOXt?format=png&name=medium">

　4点の座標は中心点から4方向に適当なVector2 width0, width1分移動した箇所を利用しています。これをImmediateMeshを使って書き込む事で実現します。
 
<img width="800" alt="4頂点を求める" src="https://pbs.twimg.com/media/F_GqiZrasAAVcxB?format=jpg&name=medium">



　SurfaceBegin()で描画開始を宣言してから、後はひたすらSurfaceAddVertex2D()で頂点情報をImmediateMeshに書き込みます。この時SurfaceBegin()の引数に Mesh.PrimitiveType.TriangleStrip を指定します。

| 引数 | 説明 |
| :-- | :-- |
| PrimitiveType.Points | 点 | 
| PrimitiveType.Lines | 直線なので２の倍数で登録しないとエラー |
| PrimitiveType.LineStrip | 2の倍数でなくともOK |
| PrimitiveType.Triangles | 三角形なので3の倍数で登録しないとエラー |
| PrimitiveType.TriangleStrip | 3の倍数でなくともOK |

　今回は単色で塗りつぶしたいのでTriangleかつ、4つの頂点を書き込むのでTriangleStripを使用しました。実装コードでは下図右側の1,2,3,4の順に書き込み、これを繰り返しています。
 　試しにPrimitiveType.LineStripでワイヤーフレーム化して観察すると、書き込む順序に応じて描画されるパターンに変化がおきている事がわかります。下図左の順でメッシュに頂点を書き込むと、進行方向によって軌跡にくびれが生じてしまいます。


<img width="800" alt="そうはならんやろ" src="https://pbs.twimg.com/media/F_HA7rQawAAPVXi?format=png&name=medium">
 
　リファレンスはこちらを参照してください。
 
https://docs.godotengine.org/ja/4.x/classes/class_mesh.html#enum-mesh-primitivetype

## サンプルノード
　ルートノードはテスト用にシーン実行するための単なるプレースフォルダです。
　この構成ではスクリプトが２つアタッチされています。Sprite2Dにアタッチしたスクリプトは単に弾を動かすためだけに用意しました。Transformを持っていて移動できれば何でも良いです。
 　MeshInstance2DにアタッチしたスクリプトがTrailRendererの実装です。
　実際に利用する際はMeshInstance2D(とアタッチしたスクリプト)だけをシーン化した汎用Trail Rendererシーンを作成し、軌跡を発生させたいオブジェクトの子ノードにして利用すると良いでしょう。
 
<img width="200" alt="Scene" src="https://pbs.twimg.com/media/F_GzeIpb0AATSYJ?format=png&name=small">
 

 Sprite2Dには赤い光点のようなスプライトを取り付け、画面中央に来るようルートノード相対で初期位置を変更しています。

<img width="200" alt="Sprite2D" src="https://pbs.twimg.com/media/F_GzpGcb0AAFzyq?format=png&name=900x900">

　MeshInstance2Dは初期状態だとMeshが空なので適当にメッシュを追加してください。何か設定されていれば直方体でも球でもかまいません。
　インスペクタを介して"Internal parameters"から生存時間・初期サイズ・描画色を設定可能にしてあります。

<img width="200" alt="MeshInstance2D" src="https://pbs.twimg.com/media/F_Gz0EzbUAA97uO?format=png&name=small">

## サンプルコード

```Sprite2D用(RedLaser.cs)
using Godot;
using System;

public partial class RedLaser : Sprite2D
{
	private float _degree = 0;
	private float _radius = 100;
	private Vector2 _initPos;

	public override void _Ready()
	{
		_initPos = Position;
	}

	public override void _Process(double delta)
	{
		_degree += 1;
		Vector2 vec = Vector2.Zero;
		vec.X = _radius * Mathf.Sin(Mathf.DegToRad(_degree * 1));
		vec.Y = _radius * Mathf.Sin(Mathf.DegToRad(_degree * 2));
		Position = vec + _initPos;
	}
}

```


```MeshInstance2D用(TrialRenderer.cs)
using Godot;
using System;
using System.Collections.Generic;

public partial class TrialRenderer : MeshInstance2D
{
	[ExportGroup("Internal parameters")]
	[Export] private int _lifeTime = 120;
	[Export] private float _startWidth = 4;
	[Export] private Color _startColor = Colors.White;
	
	private ImmediateMesh _mesh;
	private List<Vector2> _points = new List<Vector2>();//コンテナはVector2が保存出来れば何でも良い

	public override void _Ready()
	{
		_mesh = new ImmediateMesh();
		this.Mesh = _mesh;
	}

	public override void _Process( double delta )
	{
		_points.Add(GlobalTransform.Origin);
		if(_points.Count > _lifeTime)
			_points.RemoveAt(0);

		_mesh.ClearSurfaces();
		_mesh.SurfaceBegin(Mesh.PrimitiveType.TriangleStrip);
		{	// SurfaceBeginとSurfaceEndの区間を明示するだけのカーリーブラケット
			// 見やすくする以上の意味はありません
			_mesh.SurfaceSetColor(_startColor);
			for(int i = 0; i < _points.Count; i++){
				float unit = _startWidth * i /_lifeTime;
				Vector2 width0 = new Vector2(unit, unit);
				Vector2 width1 = new Vector2(unit, -unit);
				
				_mesh.SurfaceAddVertex2D(ToLocal(_points[i] + width0));
				_mesh.SurfaceAddVertex2D(ToLocal(_points[i] + width1));
				_mesh.SurfaceAddVertex2D(ToLocal(_points[i] - width0));
				_mesh.SurfaceAddVertex2D(ToLocal(_points[i] - width1));
			}
		}
		_mesh.SurfaceEnd();
	}
}

```

## 動かしてみよう
　滾る漢のトレイルレンダラー！

https://x.com/cabbagestole/status/1725363288215847085?s=20

## おわりに
　機能が無ければ自作するしか無いので自作しました、というお話でした。
　実装を見ても分かる通りゴリゴリにループしてます。このように汎用的かつオーバーヘッドの高い機能はC#やGDScriptといったユーザー側コードではなく、相対的にオーバーヘッドの少ない(C++)エンジン側で機能実装してほしいものです。
　GDExtention(C++)で作る事も頭をよぎりましたが~~ヘッダとCPPファィルの２つを作るのが最近めっきり億劫になってきたのと~~性能面でまだ困ってないので、エンジン側うんぬんは単なるお気持ち表明です。