はじめに
前回の記事では G-Buffer を利用して Raymarching で数式から図形を描画する方法を紹介しました。
G-Buffer を利用して Raymarching して描画した図形はコライダが無いため、ポリゴンベースのオブジェクトと見た目の上での干渉はあるものの、そのままでは通り抜けられてしまいます。これを解決するためには、空のゲームオブジェクトを生成してコライダの形状だけポリゴンで近似して作成したコライダのコンポーネントをアタッチしたり、id:i-saint さんの記事のようにオブジェクトスペースで行い、オブジェクト自体は通常のコリジョンを持っている形式にしたりと幾つかアイディアが考えられます。
本エントリでは、別のアイディアとして、CPU 側でも Raymarching を行い、その結果を Rigidbody に反映する手法を試してみたので、その方法をご紹介します。
デモ
ダウンロード
概要
描画に使った距離関数とまったく同じ式を C# 側でも作成し、この数式を利用してオブジェクトの進行方向にレイを 1 本発射し、この結果を利用することでオブジェクトから衝突位置までの距離が算出できます。シェーダ側でやったのと同じように偏微分すれば法線情報も得られるので、これを利用して衝突時の力の受ける方向も算出可能です。シェーダでは全てのピクセルに対してレイをうっていましたが、本方式であればオブジェクト一つにつきレイは 1 つで済むので、計算量はとても少ないです。また、スクリーンスペースではなくワールドで行うため、カメラから見えないあるオブジェクトの裏側や画面外でも正しく衝突が行われます。
距離関数の移植
シェーダで行っていた処理を全て C# に書き直します。たとえば前回の図形を例にとって見てみます。
シェーダ
float3 mod(float3 a, float3 b) { return frac(abs(a / b)) * abs(b); } float smoothMin(float d1, float d2, float k) { float h = exp(-k * d1) + exp(-k * d2); return -log(h) / k; } float3 repeat(float3 pos, float3 span) { return mod(pos, span) - span * 0.5; } float sphere(float3 pos, float radius) { return length(pos) - radius; } float floor(float3 pos) { return dot(pos, float3(0.0, 1.0, 0.0)) + 1.0; } float roundBox(float3 pos, float3 size, float round) { return length(max(abs(pos) - size * 0.5, 0.0)) - round; } float DistanceFunc(float3 pos) { float r = 0.2; float d1 = roundBox(repeat(pos, float3(6, 6, 6)), 1, r); float d2 = sphere(pos, 3.0); float d3 = floor(pos - float3(0, -3, 0)); return smoothMin(smoothMin(d1, d2, 1.0), d3, 1.0); } float3 GetNormal(float3 pos) { const float d = 0.001; return 0.5 + 0.5 * normalize(float3( DistanceFunc(pos + float3( d, 0.0, 0.0)) - DistanceFunc(pos + float3( -d, 0.0, 0.0)), DistanceFunc(pos + float3(0.0, d, 0.0)) - DistanceFunc(pos + float3(0.0, -d, 0.0)), DistanceFunc(pos + float3(0.0, 0.0, d)) - DistanceFunc(pos + float3(0.0, 0.0, -d)))); }
C# への移植
ベクトル周りの要素ごとの関数は Mathf
に無いので、自分で書かないとならないのだけ少し面倒です。
using UnityEngine; public static class DistanceFunction { public static Vector3 Max(Vector3 posA, Vector3 posB) { return new Vector3( Mathf.Max(posA.x, posB.x), Mathf.Max(posA.y, posB.y), Mathf.Max(posA.z, posB.z)); } public static Vector3 Abs(Vector3 pos) { return new Vector3( Mathf.Abs(pos.x), Mathf.Abs(pos.y), Mathf.Abs(pos.z)); } public static Vector3 Frac(Vector3 pos) { return new Vector3( pos.x - Mathf.Floor(pos.x), pos.y - Mathf.Floor(pos.y), pos.z - Mathf.Floor(pos.z)); } public static Vector3 Divide(Vector3 a, Vector3 b) { return new Vector3(a.x / b.x, a.y / b.x, a.z / b.z); } public static Vector3 Multiply(Vector3 a, Vector3 b) { return new Vector3(a.x * b.x, a.y * b.x, a.z * b.z); } public static Vector3 Mod(Vector3 pos, Vector3 span) { return Multiply(Frac(Abs(Divide(pos, span))), Abs(span)); } public static Vector3 Repeat(Vector3 pos, Vector3 span) { return Mod(pos, span) - span * 0.5f; } public static float RoundBox(Vector3 pos, float size, float round) { return Max(Abs(pos) - Vector3.one * size, Vector3.zero).magnitude - round; } public static float Sphere(Vector3 pos, float radius) { return pos.magnitude - radius; } public static float Floor(Vector3 pos) { return Vector3.Dot(pos, Vector3.up) + 1f; } public static float SmoothMin(float d1, float d2, float k) { float h = Mathf.Exp(-k * d1) + Mathf.Exp(-k * d2); return -Mathf.Log(h) / k; } public static float CalcDistance(Vector3 pos) { float d1 = RoundBox(Repeat(pos, new Vector3(6, 6, 6)), 1, 0.2f); float d2 = Sphere(pos, 3f); float d3 = Floor(pos - new Vector3(0, -3, 0)); return SmoothMin(SmoothMin(d1, d2, 1f), d3, 1f); } public static Vector3 CalcNormal(Vector3 pos) { var d = 0.01f; return new Vector3( CalcDistance(pos + new Vector3( d, 0f, 0f)) - CalcDistance(pos + new Vector3(-d, 0f, 0f)), CalcDistance(pos + new Vector3(0f, d, 0f)) - CalcDistance(pos + new Vector3(0f, -d, 0f)), CalcDistance(pos + new Vector3(0f, 0f, d)) - CalcDistance(pos + new Vector3(0f, 0f, -d))).normalized; } }
次にこれを使って Raymarching する部分を書いてみます。
まずは距離と法線を調べる
コードはかなり短く書けます。
[ExecuteInEditMode] public class Mover : MonoBehaviour { private const float MAX_DIST = 10f; private const float MIN_DIST = 0.01f; void Update() { var dist = 0f; var len = 0f; var pos = transform.position; var dir = transform.forward; for (int i = 0; i < 100; ++i) { dist = DistanceFunction.CalcDistance(pos); len += dist; pos += dir * dist; if (dist < MIN_DIST || len > MAX_DIST) break; } if (dist > MIN_DIST) { Debug.DrawLine(transform.position, pos, Color.blue); } else { var normal = DistanceFunction.CalcNormal(pos); Debug.DrawLine(transform.position, pos, Color.red); Debug.DrawLine(pos, pos + normal, Color.yellow); } } }
これでオブジェクトからの距離とレイが当たった先の法線を得ることが出来ます。
Rigidbody をコントロールする
同じように得た情報を使って Rigidbody をコントロールします。オブジェクトに Rigidbody コンポーネントと Sphere Collider コンポーネントをアタッチしておきます。
public class Mover : MonoBehaviour { [SerializeField] float radius = 0.5f; [SerializeField] float friction = 0.3f; [SerializeField] float angularFriction = 0.6f; [SerializeField] float restitution = 0.9f; private const float MAX_DIST = 10f; private const float MIN_DIST = 0.01f; private const float STATIC_GRAVITY_MODIFIER = 1.2f; private const float BUERIED_GRAVITY_MODIFIER = 3f; private Rigidbody rigidbody_; struct RaymarchingResult { public int loop; public bool isBuried; public float distance; public float length; public Vector3 direction; public Vector3 position; public Vector3 normal; } RaymarchingResult Raymarching(Vector3 dir) { var dist = 0f; var len = 0f; var pos = transform.position + radius * dir; var loop = 0; for (loop = 0; loop < 10; ++loop) { dist = DistanceFunction.CalcDistance(pos); len += dist; pos += dir * dist; if (dist < MIN_DIST || len > MAX_DIST) break; } var result = new RaymarchingResult(); result.loop = loop; result.isBuried = DistanceFunction.CalcDistance(transform.position) < MIN_DIST; result.distance = dist; result.length = len; result.direction = dir; result.position = pos; result.normal = DistanceFunction.CalcNormal(pos); return result; } void Start() { rigidbody_ = GetComponent<Rigidbody>(); } void FixedUpdate() { var ray = Raymarching(rigidbody_.velocity.normalized); var v = rigidbody_.velocity; var g = Physics.gravity; // 埋まっているときは脱出方向へ力をかける if (ray.isBuried) { rigidbody_.AddForce((rigidbody_.mass * g.magnitude * BUERIED_GRAVITY_MODIFIER) * ray.normal); // 衝突時は速度の跳ね返りの計算とめり込み対策 } else if (ray.length < MIN_DIST) { var prod = Vector3.Dot(v.normalized, ray.normal); // 衝突面垂直方向速度 var vv = (prod * v.magnitude) * ray.normal; // 衝突面水平方向速度 var vh = v - vv; // 摩擦と跳ね返り係数を考慮して減速 rigidbody_.velocity = vh * (1f - friction) + (-vv * restitution); // 静止時に埋まりを避けるために鉛直上向きの垂直抗力(ちょっと強めに補正)を加える rigidbody_.AddForce(-rigidbody_.mass * STATIC_GRAVITY_MODIFIER * g); // 回転の摩擦は適当に与える rigidbody_.AddTorque(-rigidbody_.angularVelocity * (1f - angularFriction)); } } }
これで以下のようになります。
Raymarching の結果をいろいろと使えるように構造体を作成して格納して、これを参照して Rigidbody を操作します。レイの方向は先ほどは transform.forward
を例に取りましたが、rigidbody.velocity
方向に変更します。衝突はレイの長さが閾値以下になったらとし、その際に衝突面に対して垂直・水平方向に速度を分解、摩擦・跳ね返り係数を考慮して跳ね返り後の速度を算出します。
ただしこれだけだと距離関数のオブジェクトの中にズブズブと埋まってしまうので、適当に調整した垂直抗力を加えています。オブジェクトは球を仮定してレイの発射元に radius
が足されているのもめり込み対策です。また、距離関数で描いたオブジェクトの中に入ってしまった時の判定は、初回のスフィアキャストが既に閾値以下でオブジェクトに到達しているかどうかで見ています。埋まった時は法線方向に重力と比較して強めの力をかけることで脱出するようにしています。
コライダは Box Collider でも問題ないのですが、レイの判定と Rigidbody の操作で球を仮定しているので、見た目の挙動がちょっとアレになります。
おわりに
それっぽく出来たので満足です。Rigidbody 操作とはいかなくとも、衝突情報が取れればゲームには使えそうだな、と考えていたので、思っていた以上の成果でした。