前回は、バウンディングボックスとバウンディングスフィアを使って、 オブジェクトがカメラに写っているかどうかや、タッチやドラッグがされたかどうかを確認しました。 また、これによって偽陽性による誤検知が引き起こされる可能性があることも分かりました。 場合によっては、交差しているかどうかを確認するためにより正確な方法が必要なこともあります。 衝突形状を使用すると、ほとんどパフォーマンスコストをかけずに非常に正確な交差確認を行うことができます。 衝突形状も、衝突判定と物理演算にとって基本的なことになります。
前回のチュートリアルでやったところから説明を始め、このチュートリアルでのベースとして前回のコードを使用します。 今回もコードと素材は githubで使用でき、実行可能なテストもあります。
現状では isVisible
メソッドと getObject
メソッドを使って、 3D オブジェクト (GameObject
) と視錐台やrayを比較しています。
それでは、これらのチェック処理を GameObject
クラスへ移してコードを少し綺麗にしましょう:
public class ShapeTest extends InputAdapter implements ApplicationListener {
public static class GameObject extends ModelInstance {
public final Vector3 center = new Vector3();
public final Vector3 dimensions = new Vector3();
public final float radius;
private final static BoundingBox bounds = new BoundingBox();
private final static Vector3 position = new Vector3();
public GameObject (Model model, String rootNode, boolean mergeTransform) {
super(model, rootNode, mergeTransform);
calculateBoundingBox(bounds);
bounds.getCenter(center);
bounds.getDimensions(dimensions);
radius = dimensions.len() / 2f;
}
public boolean isVisible(Camera cam) {
return cam.frustum.sphereInFrustum(transform.getTranslation(position).add(center), radius);
}
/** @return -1 on no intersection, or when there is an intersection: the squared distance between the center of this
* object and the point on the ray closest to this object when there is intersection. */
public float intersects(Ray ray) {
transform.getTranslation(position).add(center);
final float len = ray.direction.dot(position.x-ray.origin.x, position.y-ray.origin.y, position.z-ray.origin.z);
if (len < 0f)
return -1f;
float dist2 = position.dst2(ray.origin.x+ray.direction.x*len, ray.origin.y+ray.direction.y*len, ray.origin.z+ray.direction.z*len);
return (dist2 <= radius * radius) ? dist2 : -1f;
}
}
...
}
テストの名前をShapeTest
に変更し、計算で使用する position
という名前の static
変数を追加しています。
isVisible
メソッドは基本的には前回のチュートリアルで見たもののコピーですが、メソッドチェインを使って少し修正して1行にしています。
intersects
メソッドは基本的にgetObject
メソッドの関連する部分(for
-ループの部分) のコピーです。
私のチュートリアルではコメントであまり多くの説明をしていませんが、このメソッドはどういった結果になるのかを説明するコメントが少なくともこれくらいは必要なので、コメントで多くの説明をしています。
それでは、isVisible(final Camera cam, final GameObject instance)
メソッドを削除したので、それに合わせてrender
メソッドと getObject
メソッドを変更します。
@Override
public void render () {
...
for (final GameObject instance : instances) {
if (instance.isVisible(cam)) {
modelBatch.render(instance, environment);
visibleCount++;
}
}
...
}
...
public int getObject (int screenX, int screenY) {
Ray ray = cam.getPickRay(screenX, screenY);
int result = -1;
float distance = -1;
for (int i = 0; i < instances.size; ++i) {
final float dist2 = instances.get(i).intersects(ray);
if (dist2 >= 0f && (distance < 0f || dist2 <= distance)) {
result = i;
distance = dist2;
}
}
return result;
}
これらの変更は簡単なものです。今回はinstance.isVisible(cam)
と instances.get(i).intersects(ray)
を使用します。
これを実行すると、前回やった時と全く同じ内容になります。バウンディングスフィアを使った交差チェックが不正確なことも含めて。
我々の目標はこの精度を上げることです。そしてそれを成し遂げるために衝突形状を使用します。
衝突形状とは、例えば視錐台やrayの取得に対して形状を確認するための、数学的方法の小さな集まりです。
この考え方は、異なる形状のオブジェクトごとに異なる形状を使用するということです。
これを実装する最も簡単な方法は、小さな interface
を作成し、それを使って形状を識別することです。
public class ShapeTest extends InputAdapter implements ApplicationListener {
public interface Shape {
public abstract boolean isVisible(Matrix4 transform, Camera cam);
/** @return -1 on no intersection, or when there is an intersection: the squared distance between the center of this
* object and the point on the ray closest to this object when there is intersection. */
public abstract float intersects(Matrix4 transform, Ray ray);
}
public static class GameObject extends ModelInstance {
public Shape shape;
public GameObject (Model model, String rootNode, boolean mergeTransform) {
super(model, rootNode, mergeTransform);
}
public boolean isVisible(Camera cam) {
return shape == null ? false : shape.isVisible(transform, cam);
}
public float intersects(Ray ray) {
return shape == null ? -1f : shape.intersects(transform, ray);
}
}
...
}
Shape
という名前の簡単な interface
を追加しました。次にこれの実装をします。
Shape は複数のオブジェクトで(再)利用できるので、確認をしたいオブジェクトの変換マトリクス (位置情報や回転情報やスケール情報)を指定するための新たな引数も追加しました。
これで、GameObject
はshapeへの参照を保持し、そのshapeの適切なメソッド (isVisible
やintersects
) を呼び出すだけです。
shape の設定は任意にしました ( nullにできます)。
オブジェクトがshapeを持っていない場合は既定値を返すだけです。
最後に、centerや dimensionや radius といった変数、 bounds やpositionといったstatic
変数を削除しました。今回の変更によって、これらの値はshape が管理するようになったからです。
実際のところ、ほぼ全ての shapeでは自身の中心や容積を知っておく必要があります。 そのため、shape 実装を得るための小さな基底クラスを作成した方が良いかもしれません。
public static abstract class BaseShape implements Shape {
protected final static Vector3 position = new Vector3();
public final Vector3 center = new Vector3();
public final Vector3 dimensions = new Vector3();
public BaseShape(BoundingBox bounds) {
bounds.getCenter(center);
bounds.getDimensions(dimensions);
}
}
abstract BaseShape
クラスでは、centerベクトルと dimensions ベクトルを保持します。
また、全ての shapeで自身の中心位置を計算する必要があるため、static protected position
ベクトルも追加しています。
centerベクトルとdimensionsベクトルを設定するために、BoundingBox
を引数として受け取ってそれに応じて各変数を設定するコンストラクタを追加しています。
あなたの要件次第では、抽象基底クラスをのみを使ってインタフェースは使わない方が良い場合や、インタフェースにcenter やdimensionsを取得するために新たなメソッドを追加する方が良い場合もあります。
さて、これらの変更内容をテストするには、少なくとも一つのshapeを実装する必要があります。 それでは、これまでにも使ったことのある球体のshape を使って実装をしてみましょう。:
public static class Sphere extends BaseShape {
public float radius;
public Sphere(BoundingBox bounds) {
super(bounds);
Vector3 dimensions=new Vector3();
radius = bounds.getDimensions(dimensions).len() / 2f;
}
@Override
public boolean isVisible(Matrix4 transform, Camera cam) {
return cam.frustum.sphereInFrustum(transform.getTranslation(position).add(center), radius);
}
@Override
public float intersects(Matrix4 transform, Ray ray) {
transform.getTranslation(position).add(center);
final float len = ray.direction.dot(position.x-ray.origin.x, position.y-ray.origin.y, position.z-ray.origin.z);
if (len < 0f)
return -1f;
float dist2 = position.dst2(ray.origin.x+ray.direction.x*len, ray.origin.y+ray.direction.y*len, ray.origin.z+ray.direction.z*len);
return (dist2 <= radius * radius) ? dist2 : -1f;
}
}
基本的にはこれだけでコードを動かしています。クラスSphere
は抽象クラスBaseShape
を継承しています。
コンストラクタ内では BaseShape
のコンストラクタを呼び出し、以前やったようにradius (半径)を設定します。
同様に、isVisible
メソッドと intersects
メソッドは GameObject クラスからコピーしてtransform引数を追加しただけです。
これであとテストに必要なものは、shape を各オブjジェクトに割り当てるようにテストクラスを更新することだけです。 シーン内には三つの異なる shape (船とブロックとインベーダー)があるので、各オブジェクトで再利用する三つのshapeが必要です。
public class ShapeTest extends InputAdapter implements ApplicationListener {
...
protected Shape blockShape;
protected Shape invaderShape;
protected Shape shipShape;
...
private BoundingBox bounds = new BoundingBox();
private void doneLoading () {
Model model = assets.get("data" + "/invaderscene.g3db", Model.class);
for (int i = 0; i < model.nodes.size; i++) {
String id = model.nodes.get(i).id;
GameObject instance = new GameObject(model, id, true);
if (id.equals("space")) {
space = instance;
continue;
}
instances.add(instance);
if (id.equals("ship")) {
if (shipShape == null) {
instance.calculateBoundingBox(bounds);
shipShape = new Sphere(bounds);
}
instance.shape = shipShape;
ship = instance;
}
else if (id.startsWith("block")) {
if (blockShape == null) {
instance.calculateBoundingBox(bounds);
blockShape = new Sphere(bounds);
}
instance.shape = blockShape;
blocks.add(instance);
}
else if (id.startsWith("invader")) {
if (invaderShape == null) {
instance.calculateBoundingBox(bounds);
invaderShape = new Sphere(bounds);
}
instance.shape = invaderShape;
invaders.add(instance);
}
}
loading = false;
}
...
}
三つの変数: blockShapeと invaderShape とshipshapeを追加しました。
doneLoading
メソッド内では既に各オブジェクトのIDを確認して、それが船なのかブロックなのかインベーダーなのかが分かっています。
ここでは、そのオブジェクトのshape が既に作成されているかどうかを確認します。
作成されていない場合は、そのオブジェクトのバウンディングボックスを計算して、Sphere
としてshape を作成します。
最後にそのshape を instance.shape
に割り当てます。
そしてこのコードを実行すると、改めて何も変わっていないことが分かります。 今回行ったのは、コードを移動したことと1つインタフェースと2つのクラスを追加したことです。 ですが、衝突形状に関するより多くの柔軟性と可能性を得ることができました。 あと必要なのは、異なる形状をした各オブジェクト用のshapeを作成することです。 これによりパフォーマンスコストをほとんど掛けずにより良い精度を得ることできるようになります。 それでは、ブロックModelのshapeから始めてみましょう。この場合、基本的に以前に使用したバウンディングボックスを使用できます。
public static class Box extends BaseShape {
public Box(BoundingBox bounds) {
super(bounds);
}
@Override
public boolean isVisible(Matrix4 transform, Camera cam) {
return cam.frustum.boundsInFrustum(transform.getTranslation(position).add(center), dimensions);
}
@Override
public float intersects(Matrix4 transform, Ray ray) {
transform.getTranslation(position).add(center);
if (Intersector.intersectRayBoundsFast(ray, position, dimensions)) {
final float len = ray.direction.dot(position.x-ray.origin.x, position.y-ray.origin.y, position.z-ray.origin.z);
return position.dst2(ray.origin.x+ray.direction.x*len, ray.origin.y+ray.direction.y*len, ray.origin.z+ray.direction.z*len);
}
return -1f;
}
}
上記コードでは Box
shapeを追加しています。
center 変数とdimensions 変数は BaseShape
内で既に設定されているので、必要なメソッドを実装するだけです。
isVisible
メソッドでは、以前にも使用した cam.frustum.boundsInFrustum
メソッドを使用します。
同様にintersects
メソッドでは、Intersector.intersectRayBoundsFast
メソッドを使って交差の確認をします。
交差していた場合は、そのオブジェクトの中心に最も近いray上の点を算出して、オブジェクトとその点との間の距離の2乗を戻り値として返します。
交差していない場合は、Sphere shapeの時と同様に-1fを返します。
まあ、これは簡単でしたね。それではdoneLoading
メソッドを更新し、 sphere shapeではなくこのshape を使用するようにしましょう。
private void doneLoading () {
...
else if (id.startsWith("block")) {
if (blockShape == null) {
instance.calculateBoundingBox(bounds);
blockShape = new Box(bounds);
}
instance.shape = blockShape;
blocks.add(instance);
}
...
}
そしてこれを実行してみると、このメソッドがブロックに対して非常に正確な判定をしていることが分かるでしょう。
それでは、インベーダー用のshapeも追加してみましょう。 インベーダーをよく見てみると、おおよそ円形のようなものです。 本当に丸いわけではありませんが(むしろ八角形に近い)、とても似ています。 この形状の場合は丸いものと見なすのが良いでしょう。 必要であれば、この想定を補うために半径に対して少しオフセットを加えることができますが、ほとんどの場合それは不要です。 インベーダーの上部と底部はそれほどの高さはなく、中央へ向かう勾配もそれほど急ではありません このチュートリアルを単純化するために、この形状には円盤型(円)のみを使用します。 必要であれば、例えば円柱のshapeを使うなど、高さを考慮に入れてより精度の高い交差チェックを行うことができます。
public static class Disc extends BaseShape {
public float radius;
public Disc(BoundingBox bounds) {
super(bounds);
radius = 0.5f * (dimensions.x > dimensions.z ? dimensions.x : dimensions.z);
}
@Override
public boolean isVisible (Matrix4 transform, Camera cam) {
return cam.frustum.sphereInFrustum(transform.getTranslation(position).add(center), radius);
}
@Override
public float intersects (Matrix4 transform, Ray ray) {
transform.getTranslation(position).add(center);
final float len = (position.y - ray.origin.y) / ray.direction.y;
final float dist2 = position.dst2(ray.origin.x + len * ray.direction.x, ray.origin.y + len * ray.direction.y, ray.origin.z + len * ray.direction.z);
return (dist2 < radius * radius) ? dist2 : -1f;
}
}
コンストラクタでは、円盤の半径(radius)を計算します。
球体の時とは違い(which we used for all possible rotations, more on that shortly)、半径を計算するには横幅と深さの最大値を取得しているだけです。
前述のように、小さなオフセット(例えば1.1fを掛けるなど)を追加して半径を補正することができます。
isVisible
メソッド内では、単に球体の視錐台チェックを使用しています。このメソッドではそれほど正確である必要がないためです。
intersects
メソッドは以前に実装した touchDragged
メソッド内での計算を基にしています。
まず最初にオブジェクトの中心を計算します。
次にray上でy座標がオブジェクトの中心のy座標と同じになる位置を計算します。
そしてその点とオブジェクトの中心との間の距離の2乗を計算します。
距離が半径よりも大きい場合は-1f を返し、小さい場合は距離の2乗を返します。
次に、 doneLoading
メソッドを更新してインベーターでこのshapeを使用する必要があります:
private void doneLoading () {
...
else if (id.startsWith("invader")) {
if (invaderShape == null) {
instance.calculateBoundingBox(bounds);
invaderShape = new Disc(bounds);
}
instance.shape = invaderShape;
invaders.add(instance);
}
...
}
そうしてこれを実行すると、インベーダーの選択がはるかに正確になっていることが分かるでしょう。 rayに最も近いオブジェクトではなくカメラに最も近いオブジェクトを使うようにコードを更新するのも良いかもしれません。 Just like we did before, but later changed to compensate for the inaccuracy.
既に理解していると思いますが、選択した衝突形状は、例えばrayや円錐台の確認に使用されるアルゴリズムに影響を与えます。 精度とパフォーマンスへの影響は、常にトレードオフの関係です。 例えば、仮にインベーターの衝突判定に円形ではなく八角形を使用したらどうでしょうか?はい、実装は今使用しているものより複雑になり、そこまでする価値があるが疑わしいかもしれません。 もちろん、そうした確認処理を最適化する方法もあります。例えば、まず最初にバウンディングボックスを使って確認を行い(この方法は非常に軽いうえに偽陰性による誤検知がありません)、その確認が成功した場合にのみより正確で負荷の高いアルゴリズムを使用します。
このチュートリアルでは、視錐台とray取得に対する確認ついてのみ見てきました。
これらは一般的に、 sphere-frustum, sphere-ray, box-frustum, box-ray, disc-frustum
, disc-ray
と呼ばれます。
例えばsphere-sphere, sphere-box, box-disc
など、さらに確認を追加することが可能です。
最も一般的には、これは別のクラス(以前使用したIntersector
に相当するクラス)内で行われます。
これにより衝突判定が非常に簡単に実装できます。
より複雑な形状(例えば船)に衝突形状を使用することが可能です。 そして一つのshapeを使ってModelを表現することができない場合でさえも、複数のshapeを組み合わせて使用することができます(例えば翼のshapeを作成して、それらを二つ組み合わせて船を作成する)。 また基本的な形状を使用することができない場合には、凸形状(基本的には、オブジェクトの周りにぴったりと包まれたバッグ形状)が選択肢になることもあります。 And if you really need more precision than that, you could always fall back to a concave shape or even the actual mesh data (which might be very slow).
なぜLibGDX にこうしたshape クラスが実装されていないのか、不思議に思うかもしれません。 良いshape クラスをいくつか実装するのは素晴らしいことですが、実際に必要な実装は要件に応じて異なる可能性があるのです。 ですが、Libgdx には3D物理演算拡張が実装されています。この拡張には最も良く使用されるshapeがいくつか含まれています。 実際に衝突判定や物理演算が必要な場合は、こうしたshapeを実装しているこの拡張を使うと良いでしょう。 次のチュートリアルではこの拡張について見ていきます。
以前に、boundでは回転が動作しないためsphereを使用することにしました。 同様に、今回作成した形状はオブジェクトが回転や拡大縮小した時に処理が失敗します。 これを解決するのは比較的簡単です(例えば、 ray を "shape-space"に変換する、など)。 ですが実際には、おそらくこうした数学部分を気にする必要はありません。例えば、3D物理演算拡張を使用するだけでできます。
次:libGDX 3D 物理演算のBullet ラッパーを使用する - パート1