十、使用碰撞形状

之前我们使用了一个包围盒和包围球来检查一个物体是否对于相机是可见的,是否被触摸和拖动。我们也发现这会导致假阳性。有时你需要一个更精确的方法来检查交叉点。碰撞形状可以用来得到一个非常精确的交叉检查,几乎没有性能成本。它们也是碰撞检测和物理引擎的基础。

继续上一篇的代码:

public class MainGame 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;
		}
	}
	...
}

目前,我们使用 isVisible 和 getObject 方法来比较一个3d 对象(一个游戏对象)和一个视截体或者一个射线。这里我们把这些检查移动到 GameObject 类,使代码更加简洁。添加了一个名为 position 的静态变量,以便在计算中使用。isVisible 基本上是我们在上一个教程中看到的方法的一个副本,尽管我使用方法链接将它稍微修改为一行。Intersects 方法基本上是 getObject 方法的相关部分(for循环的主体)的副本。

现在我们可以移除 isVisible 方法,并相应地修改 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;
	}

运行改代码,效果和上一篇是一模一样。包括边界球相交检验的不准确性。这一篇中我们的目标是提高这种精度,我们将使用碰撞形状来实现这一点。碰撞形状基本上是一个小的数学方法集合,用来检查一个形状,例如视截体或截线。这个想法是为每个不同形状的物体使用不同的形状。最简单的实现方法是使用一个小接口并使用它来标识形状。

public class MainGame extends InputAdapter implements ApplicationListener{
	
	public interface Shape{
		public abstract boolean isVisible(Matrix4 transform, Camera cam);
		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,接下来我们将实现它。因为 Shape 可以用于多个对象,所以我添加了一个额外的参数来指定我们要检查的对象的变换矩阵(位置、旋转和比例)。游戏对象现在保存对一个形状的引用,并简单地调用该形状的适当方法(isVisible 或 intersects)。我将形状设置为可选(它可以为 null) ,如果对象没有形状,它将简单地返回一个默认值。最后,我删除了center, dimension, radius and static bounds 和 position这几个变量。

在实践中,几乎每个形状都需要知道形状的中心和尺寸。因此,我们不妨创建一个小的基类,从中派生每个形状实现。

public class MainGame extends InputAdapter implements ApplicationListener{
	
	public interface Shape{
		public abstract boolean isVisible(Matrix4 transform, Camera cam);
		public abstract float intersects(Matrix4 transform, Ray ray);
	}
	
	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);
		}
	}
	...
}

抽象 BaseShape 类保存中心(center)向量和维度(dimensions)向量。我还添加了一个静态保存位置(position)矢量,因为所有形状都需要计算形状的中心位置。为了设置中心和维度向量,我添加了一个构造函数,它接受 BoundingBox 并相应地设置这些变量。

现在,为了测试这些更改,我们需要实现至少一个形状。让我们使用球体形状,因为这是我们之前使用的:

public static class Sphere extends BaseShape {
		public float radius;
		
		public Sphere(BoundingBox bounds) {
			super(bounds);
			Vector3 v ;
			Vector3 v3 = new Vector3();
			bounds.getDimensions(v3);
			radius = v3.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 类扩展了 baseScape 抽象类。在构造函数中,它调用 BaseShape 的构造函数,并像前面一样设置半径。同样,isVisible 和 intersects 方法也是从 GameObject 类中复制的,我们只添加了转换参数。

现在我们需要做的就是更新测试类,为每个对象分配一个形状。因为我们在场景中有三种不同的形状(船,块和入侵者) ,我们只需要三种形状,我们将为每个对象重复使用。

...
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,以确定它是一个 ship、 block 还是 invader。现在我们检查对象的形状是否已经创建。如果不是,我们计算对象的包围盒并创建球形。最后我们把它赋给 instance.shape。

现在,如果你运行这个,你会再次看到什么都没有改变。我们所做的只是移动代码,添加一个接口和两个类。然而,我们获得了更多的灵活性和碰撞形状的可能性。我们所要做的就是为每个不同形状的物体创建一个形状,以获得更好的精度,几乎没有性能成本。让我们从块模型的形状开始,基本上我们可以使用前面使用的Box。

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的Shipe类。中心和维度已经在 BaseShape 中设置好了,因此我们只需要实现所需的方法。对于 isVisible 方法,我们使用前面使用过的 cam.frustum.boundsInFrustum 方法。同样,对于 intersects 方法,我们使用 Intersector.intersectRayBoundsFast 方法检查交集。如果有一个交集,我们计算光线上离物体中心最近的点,并返回它们之间的平方距离。否则我们返回 -1f,就像我们在球体形状中做的一样。

这是相对容易的,让我们更新 doneLoading 方法来使用这个形状而不是球形。

	private void doneLoading () {
	...
			else if (id.startsWith("block")) {
				if (blockShape == null) {
					instance.calculateBoundingBox(bounds);
					blockShape = new Box(bounds);
				}
				instance.shape = blockShape;
				blocks.add(instance);
			}
	...
	}

现在,如果您运行代码,当你点击块的时候非常精确。让我们也为入侵者添加一个形状。如果你仔细观察这些入侵者,你会发现它们大致就像一个圆盘形状。它不是真正的圆(更像一个八角形) ,但它接近它。就其形状而言,假设它是圆的是可以接受的。如果需要的话,你可以在半径上增加一个小的偏移量来补偿这个假设,尽管在大多数情况下这是不必要的。入侵者的顶部和底部并不是很高,中间也不是很陡峭。为了简化本教程,我们将仅使用圆盘(圆)来表示这个形状。如果需要,您可以考虑高度,以获得更好的交叉检查,例如使用圆柱形状。

	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;
		}
}

在构造函数中,我们计算圆盘的半径。不同于球体(我们用于所有可能的旋转,更多关于那个简短) ,我们只是采取最大的宽度和深度来计算半径。如前所述,您可以添加一个小偏移量(例如乘以1.1 f)来补偿半径。在 isVisible 方法中,我们简单地使用球截头体检查,因为这种方法不需要非常精确。相交是基于我们前面实现的 touchdrag 中的计算。首先我们计算物体的中心。接下来我们计算光线上的位置,其中 y 坐标与物体中心的 y 坐标相同。接下来我们计算这个点和物体中心之间的平方距离。如果距离大于半径,则返回-1f,否则返回平方距离。接下来,需要更新 doneLoading 方法,以使用这个形状为入侵者:

private void doneLoading () {
	...
			else if (id.startsWith("invader")) {
				if (invaderShape == null) {
					instance.calculateBoundingBox(bounds);
					invaderShape = new Disc(bounds);
				}
				instance.shape = invaderShape;
				invaders.add(instance);
			}
	...
}

现在如果您运行这个命令,您将注意到选择入侵者要准确得多。您甚至可能希望更新代码,使用离摄像机最近的对象,而不是离射线最近的对象。就像我们以前做的一样,但后来为了弥补不准确而改变了。

正如您现在希望理解的那样,您选择的碰撞形状影响用于检查的算法,例如对射线或视截头体的检查。在准确性和性能影响之间总是有一个平衡。例如,如果我们真的想检查入侵者的八角形,而不是光盘形状,那该怎么办?好吧,这将是一个比我们现在使用的更复杂的实现,它可能是值得怀疑的。当然也有一些方法来优化这样的检查。例如,首先检查边界球(它非常便宜,没有假否定) ,并且只有在成功的情况下,才使用更精确和昂贵的算法。

在本教程中,我们只查看了与平截体和 pick ray 相对应的检查。这些通常被称为: 球截头体、球射线、箱截头体、箱射线、圆截头体和圆盘射线。可以添加更多的检查,例如球-球,球-盒,盒-盘等。通常这是在一个单独的类中完成的(与我们前面使用的 Intersector 类相似)。这使得碰撞侦测非常容易实现。

对于更复杂的形状(例如船) ,可以使用碰撞形状。即使不可能使用单一形状来表示一个模型,也可以使用多种形状的组合(例如创建一个翅膀形状,并将其中两种形状组合起来创建船)。当它也不可能使用基本形状,然后凸形状可能是一个选择(基本上是一个袋子紧紧包裹着对象)。如果您确实需要更高的精度,那么您可以返回到凹形,甚至是实际的网格数据(这可能非常慢)。