経緯
コンピュートシェーダー(ComputeShader)を学ぼうと思いたち、お題として大量のモブ、例えば自動車を動かす交通シミュレーターみたいなものを作ってみようと思いました。個々の自動車がそれぞれ衝突を回避しつつ適切な経路で目的地に移動できるようになるのが目標です。
ちなみに学術的なシミュレーターではなくてシムシティみたいなゲームを前提とした「それっぽく見えればOK」なレベルが目標ですので、過度な期待はなさいませんように。
記事は何回かに分けて書きます。今回は手始めに ComputeShader でテクスチャに道路を描いてみました。
▶【その2:車を生成する】
C#側実装
道路の定義
まずは道路を定義する構造体です。簡単のため分岐なしで、二点間を結ぶ線分のみをサポートします。車線数は上り/下り個別に設定できます。
/// <summary>
/// 道(ブランチ)の構造体
/// </summary>
struct Road
{
/// <summary>
/// 座標
/// </summary>
public Vector2 pos1;
public Vector2 pos2;
/// <summary>
/// 車線数
/// </summary>
public Vector2 lanes;
/// <summary>
/// コンストラクタ
/// </summary>
public Road(Vector2 pos1, Vector2 pos2, Vector2 lanes)
{
this.pos1 = pos1;
this.pos2 = pos2;
this.lanes = lanes;
}
}
コントローラー(コンポーネント)
道路を初期化して、シェーダーを使ってテクスチャに描画するコンポーネントです。
/// <summary>
/// 道の集合を管理するクラス
/// </summary>
public class RoadPlane : MonoBehaviour
{
private const int MAP_SIZE = 256;
private const int LANE_WIDTH = 8;
/// <summary>
/// 道をレンダリングするシェーダー
/// </summary>
public Shader roadShader;
/// <summary>
/// 道の更新を行うコンピュートシェーダー
/// </summary>
public ComputeShader roadsComputeShader;
/// <summary>
/// 書き込み可能なテクスチャ
/// </summary>
RenderTexture _renderTexture;
/// <summary>
/// 道のマテリアル
/// </summary>
Material _material;
/// <summary>
/// 道のコンピュートバッファ
/// </summary>
ComputeBuffer roadsBuffer;
private bool renderd;
/// <summary>
/// 破棄
/// </summary>
void OnDisable()
{
// コンピュートバッファは明示的に破棄しないと怒られます
if (roadsBuffer != null)
{
roadsBuffer.Release();
roadsBuffer = null;
}
renderd = false;
}
/// <summary>
/// 初期化
/// </summary>
void Start()
{
InitializeComputeBuffer();
renderd = false;
}
/// <summary>
/// 更新処理
/// </summary>
void Update()
{
if (renderd) return;
roadsComputeShader.SetBuffer(0, "Roads", roadsBuffer);
roadsComputeShader.SetTexture(0, "Result", _renderTexture);
roadsComputeShader.SetFloat("laneWidth", LANE_WIDTH);
roadsComputeShader.SetInt("length", roadsBuffer.count);
roadsComputeShader.Dispatch(0, _renderTexture.width / 8, _renderTexture.height / 8, 1);
renderd = true;
}
/// <summary>
/// コンピュートバッファの初期化
/// </summary>
void InitializeComputeBuffer()
{
var count = 3;
roadsBuffer = new ComputeBuffer(count, Marshal.SizeOf(typeof(Road)));
// 配列に初期値を代入する
Road[] roads = new Road[count];
var step = MAP_SIZE / (count+1);
var x = step;
for (int i = 0; i < count; i++)
{
roads[i] = new Road(new Vector2(x, 0.1f * MAP_SIZE) , new Vector2(x, 0.9f * MAP_SIZE), new Vector2(3,3));
x += step;
}
// バッファに適用
roadsBuffer.SetData(roads);
_renderTexture = new RenderTexture(MAP_SIZE, MAP_SIZE, 0);
_renderTexture.enableRandomWrite = true;
_renderTexture.useMipMap = false;
_renderTexture.Create();
Renderer ren = GetComponent<Renderer>();
_material = new Material(roadShader);
_material = ren.material;
_material.mainTexture = _renderTexture;
}
}
InitializeComputeBuffer() メソッドでは、3つ (=count) の縦に等間隔で並んだ道路をデータとして作成しています。また後半でマテリアルおよびテクスチャの初期化もしています。テクスチャの解像度=マップサイズにしています。
このコンポーネントは Plane に付けて、Plane の表面に道路を描くようにしました。
Update() メソッドでは、ComputeShaderでテクスチャに道路を描画しています。道路データは実行中は不変なので最初の一度だけ実行します。
シェーダー実装
与えられた道路データをもとにテクスチャに道路っぽい絵を描くシェーダーを作ります。
どうせ最初の一回しか描画されないので、パフォーマンスはそこまで重要ではないのですが、よく言われる「できるだけif文を使わない」書き方を追求しました。
概形
C#と受け渡しする変数定義はこんな感じにしました。
#pragma kernel CSMain
// 書き出し先のテクスチャ
RWTexture2D<float4> Result;
// 車線幅
float laneWidth;
// 道の構造体
struct Road
{
float2 pos1;
float2 pos2;
float2 lanes;
};
// 道の構造化バッファ
RWStructuredBuffer<Road> Roads;
// 8 * 8のスレッドで回す
[numthreads(8, 8, 1)]
void CSMain (uint3 dtid : SV_DispatchThreadID)
{
float4 color = float4(0.2,0.4,0.2,1); // 道でない部分の色
// テクスチャに色を書き込む
Result[dtid.xy] = color;
}
ここから CSMain での処理を実装していきます。
道路の内と外
道路の内側を塗ります。道路の幅は車線数*車線幅で決まります。
道路の色は float4(0.1,0.1,0.1,1) にしてます。完全な黒にしてないのは、シャドウを受け取ったときに、より暗くなるようにです。
// 2Dベクトル外積
inline float cross2d(float2 a, float2 b)
{
return a.x * b.y - a.y * b.x;
}
//データ数
int length;
void CSMain (uint3 dtid : SV_DispatchThreadID)
{
float4 color = float4(0.2,0.4,0.2,1); // 道でない部分の色
//int count = (int)Roads.Length; // MacのMetalモードではLengthプロパティが使えない
int count = length;
for(int i = 0; i < count; i++)
{
Road r = Roads[i];
// 各点を結ぶベクトル
float2 ap = dtid.xy - r.pos1;
float2 ab = r.pos2 - r.pos1;
float length = distance(r.pos1, r.pos2);
float distance = cross2d(ap, ab) / length;
// 距離が負ならlanes.x,正ならlanes.yを使う
float lanes = lerp(r.lanes.x, r.lanes.y, step(0, distance));
float absDist = abs(distance);
color = lerp(color, float4(0.1,0.1,0.1,1),
(1 - step(lanes * laneWidth + 1, absDist))
);
}
// テクスチャに色を書き込む
Result[dtid.xy] = color;
}
道路線分と描画座標 (dtid.xy) との距離を外積を使って求め、それが車線数 (lanes)*車線幅(laneWidth)+1 内なら道路の内側と判断します。+1したのは、後で境界線を書き加えたいからです。 【参考:点と線の距離を求める】
道路が単色なのでわかりづらいですが、distance は中央線からの距離になっています。描画点Pが線分の左右どちら側にあるかで外積の符号が反転することを利用して、上り/下りどちらの車線数を使うかを切り分けています。
上記では道路の始点・終点が無視されていますので、さらに線分の範囲内に収めることを考えます。
これは線分ABと描画点Pについて、内積 dot(AB,AP) と dot(BA,BP) がどちらも正になるという条件で求められるはずなので、試しにfor文の中を書き換えてみました。
// 2Dベクトル内積
inline float dot2d(float2 a, float2 b)
{
return a.x * b.x + a.y * b.y;
}
/////////// 中略 /////////
int count = (int)Roads.Length;
for(int i = 0; i < count; i++)
{
Road r = Roads[i];
// 各点を結ぶベクトル
float2 ap = dtid.xy - r.pos1;
float2 ab = r.pos2 - r.pos1;
float2 bp = dtid.xy - r.pos2;
float dotA = dot2d(ap, ab);
float dotB = dot2d(bp, -ab);
color = lerp(color, float4(0.1,0.1,0.1,1),
step(0, dotA * dotB)
);
}
↑ 目論見通りです。
二つの条件を掛け合わせて、以下のようにしました。
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
return float4(0.1,0.1,0.1,1);
}
// 8 * 8のスレッドで回す
[numthreads(8, 8, 1)]
void CSMain (uint3 dtid : SV_DispatchThreadID)
{
float4 color = float4(0.2,0.4,0.2,1); // 道でない部分の色
int count = (int)Roads.Length;
for(int i = 0; i < count; i++)
{
Road r = Roads[i];
// 各点を結ぶベクトル
float2 ap = dtid.xy - r.pos1;
float2 ab = r.pos2 - r.pos1;
float2 bp = dtid.xy - r.pos2;
float length = distance(r.pos1, r.pos2);
float distance = cross2d(ap, ab) / length;
// 距離が負ならlanes.x,正ならlanes.yを使う
float lanes = lerp(r.lanes.x, r.lanes.y, step(0, distance));
float absDist = abs(distance);
float dotA = dot2d(ap, normalize(ab));
float dotB = dot2d(bp, -ab);
// 最初の dotA は破線のヒントに、 dotA * dotB は線分の内側であることの条件に使う
color = lerp(color, getRoadColor(absDist, lanes, dotA),
(1 - step(lanes * laneWidth + 1, absDist)) * step(0, dotA * dotB)
);
}
// テクスチャに色を書き込む
Result[dtid.xy] = color;
}
ついでに、道路の内側の色を返す getRoadColor() を関数として切り出しました。
今は受け取った引数も使わずに固定値を返すだけですが、この後改造していきます。
白線を引く
中央線とついでに両脇を白く塗りたいと思います。getRoadColor() を次のように書き換えました。
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
float edges = smoothstep(0.9, 1.0, abs(distance - lanes * laneWidth)) * smoothstep(0.9, 1.0, distance);
return lerp(float4(0.1,0.1,0.1,1), float4(1,1,1,1), 1 - edges);
}
(当初はsmoothstepではなく、step関数を使っていたのですが、斜めの道路がジャギジャギになるので、smoothstepに変えました。)
結果:
車線境界線(白い破線)を引く
さらに、車線境界線として破線を引きたいと思います。これはちょっと面倒です。
まず、破線の元となる縞々を作ります。
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
float zebbla = step(0, sin(length * 2 / laneWidth));
return lerp(float4(0.1,0.1,0.1,1), float4(1,1,1,1), zebbla);
}
length は描画点Pから道路線分に垂線を降ろした時の交点の、道路始点からの距離を期待しており、これは二つ上のソースで float dotA = dot2d(ap, normalize(ab));
として計算したものを使っています。つまりは正規化した道路線分ABベクトルとAPベクトルとの内積です。
これを車線幅(laneWidth)で補正して sin 関数に入れ、step 関数で二値化しています。
他方で、(破線と実線をあわせた)境界線とそうでない部分の塗分け条件を作ります。fmod 関数を使って車線幅で割り切れる部分を白く塗っています。
(smoothstep は斜めの道路のジャギ対策として使用)
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
float lines = 1 - smoothstep(0.9, 1.0, fmod(distance, laneWidth));
return lerp(float4(0.1,0.1,0.1,1), float4(1,1,1,1), lines);
}
ここまでの三条件を組み合わせて、車線境界線のみ破線になるようにしました。
図形を組み合わせた式の考え方の基本はこれです。
- AとBの重なり部分(AND)=A*B
- AとBの合体(OR)=1-(1-A)*(1-B)
※図形は0/1で図形外/内を判別するものとして、lerp関数を参考演算子のように使って色を塗分けます。
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
float zebbla = step(0, sin(length * 2 / laneWidth));
float edges = smoothstep(0.9, 1.0, abs(distance - lanes * laneWidth)) * smoothstep(0.9, 1.0, distance);
float mask = 1 - edges * zebbla;
float lines = 1 - smoothstep(0.9, 1.0, fmod(distance, laneWidth));
return lerp(float4(0.1,0.1,0.1,1), float4(1,1,1,1), lines * mask);
}
Macでは使えない機能があった
メインは Windows で開発してるのですが、Mac で動作確認したところ「Metal では buffer.Length は使えないよ」的な警告が出て一面緑になりました。
Roads.Length がダメってことらしいので、仕方なく別途 length というパラメーターを追加してスクリプトからデータ数をセットしてやりました。
まとめ
スクリプト側で用意した道路情報を元に、ComputeShader でテクスチャに道路を描画して、それをPlaneに張り付けて表示することができました。
今回実装したコードでは、始点と終点を結ぶ直線道路しか表現できませんが、方向は自由に変えられます。また上り/下りの車線数を個別に指定できます。
最終コード
#pragma kernel CSMain
// 書き出し先のテクスチャ
RWTexture2D<float4> Result;
// 車線幅
float laneWidth;
// 道の構造体
struct Road
{
float2 pos1;
float2 pos2;
float2 lanes;
};
// 道の構造化バッファ
RWStructuredBuffer<Road> Roads;
// 2Dベクトル外積
inline float cross2d(float2 a, float2 b)
{
return a.x * b.y - a.y * b.x;
}
// 2Dベクトル内積
inline float dot2d(float2 a, float2 b)
{
return a.x * b.x + a.y * b.y;
}
// 道の色を返す
inline float4 getRoadColor(float distance, float lanes, float length)
{
float zebbla = step(0, sin(length * 2 / laneWidth));
float edges = smoothstep(0.9, 1.0, abs(distance - lanes * laneWidth)) * smoothstep(0.9, 1.0, distance);
float mask = 1 - edges * zebbla;
float lines = 1 - smoothstep(0.9, 1.0, fmod(distance, laneWidth));
return lerp(float4(0.1,0.1,0.1,1), float4(1,1,1,1), lines * mask);
}
// 8 * 8のスレッドで回す
[numthreads(8, 8, 1)]
void CSMain (uint3 dtid : SV_DispatchThreadID)
{
float4 color = float4(0.2,0.4,0.2,1); // 道でない部分の色
/*
// テクスチャサイズ取得
float xs;
float ys;
Result.GetDimensions(xs,ys);
*/
int count = (int)Roads.Length;
for(int i = 0; i < count; i++)
{
Road r = Roads[i];
// 各点を結ぶベクトル
float2 ap = dtid.xy - r.pos1;
float2 ab = r.pos2 - r.pos1;
float2 bp = dtid.xy - r.pos2;
float length = distance(r.pos1, r.pos2);
float distance = cross2d(ap, ab) / length;
// 距離が負ならlanes.x,正ならlanes.yを使う
float lanes = lerp(r.lanes.x, r.lanes.y, step(0, distance));
float absDist = abs(distance);
float dotA = dot2d(ap, normalize(ab));
float dotB = dot2d(bp, -ab);
// 最初の dotA は破線のヒントに、 dotA * dotB は線分の内側であることの条件に使う
color = lerp(color, getRoadColor(absDist, lanes, dotA),
(1 - step(lanes * laneWidth + 1, absDist)) * step(0, dotA * dotB)
);
}
// テクスチャに色を書き込む
Result[dtid.xy] = color;
}
【参考: 【Unityシェーダ入門】シェーダだけで描く図形10選】
【参考: [Unity]コンピュートシェーダー(GPGPU)で画面を動かす】