九、与游戏中的3D物体交互

在本教程中,我们将通过检查哪个对象被触摸/点击,以及通过在 3d 世界中拖动对象来与其进行交互来继续学习。

以下代码是从上一篇中修改后得到的代码:

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.assets.AssetManager;
import com.badlogic.gdx.graphics.Camera;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.PerspectiveCamera;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g3d.Environment;
import com.badlogic.gdx.graphics.g3d.Material;
import com.badlogic.gdx.graphics.g3d.Model;
import com.badlogic.gdx.graphics.g3d.ModelBatch;
import com.badlogic.gdx.graphics.g3d.ModelInstance;
import com.badlogic.gdx.graphics.g3d.attributes.ColorAttribute;
import com.badlogic.gdx.graphics.g3d.environment.DirectionalLight;
import com.badlogic.gdx.graphics.g3d.utils.CameraInputController;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.math.collision.BoundingBox;
import com.badlogic.gdx.math.collision.Ray;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.utils.Array;

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();
		
		public GameObject(Model model, String rootNode, boolean mergeTransform) {
			super(model, rootNode, mergeTransform);
			calculateBoundingBox(bounds);
			bounds.getCenter(center);
			bounds.getDimensions(dimensions);
			radius = dimensions.len() / 2f;
		}
	}
	
	private static final String data = "model/scene";
	protected PerspectiveCamera cam;
	protected CameraInputController camController;
	protected ModelBatch modelBatch;
	protected AssetManager assets;
	protected Array<GameObject> instances = new Array<GameObject>();
	protected Environment environment;
	protected boolean loading;

	protected Array<GameObject> blocks = new Array<GameObject>();
	protected Array<GameObject> invaders = new Array<GameObject>();
	protected GameObject ship;
	protected GameObject space;

	protected Stage stage;
	protected Label label;
	protected BitmapFont font;
	protected StringBuilder stringBuilder;

	private int selected = -1, selecting = -1;
	private Material selectionMaterial;
	private Material originalMaterial;
	
	@Override
	public void create() {
		stage = new Stage();
		font = new BitmapFont();
		label = new Label(" ", new Label.LabelStyle(font, Color.WHITE));
		stage.addActor(label);
		stringBuilder = new StringBuilder();

		modelBatch = new ModelBatch();
		environment = new Environment();
		environment.set(new ColorAttribute(ColorAttribute.AmbientLight, 0.4f, 0.4f, 0.4f, 1f));
		environment.add(new DirectionalLight().set(0.8f, 0.8f, 0.8f, -1f, -0.8f, -0.2f));

		cam = new PerspectiveCamera(67, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
		cam.position.set(0f, 7f, 10f);
		cam.lookAt(0, 0, 0);
		cam.near = 1f;
		cam.far = 300f;
		cam.update();

		camController = new CameraInputController(cam);
		Gdx.input.setInputProcessor(new InputMultiplexer(this, camController));

		assets = new AssetManager();
		assets.load(data + "/invaderscene.g3db", Model.class);
		loading = true;
		
		selectionMaterial = new Material();
		selectionMaterial.set(ColorAttribute.createDiffuse(Color.ORANGE));
		originalMaterial = new Material();
	}

	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"))
                ship = instance;
            else if (id.startsWith("block"))
                blocks.add(instance);
            else if (id.startsWith("invader"))
                invaders.add(instance);
        }
     
        loading = false;
    }

	private int visibleCount;

	@Override
	public void render() {
		if (loading && assets.update())
			doneLoading();
		camController.update();

		Gdx.gl.glViewport(0, 0, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);

		modelBatch.begin(cam);
		visibleCount = 0;
		for ( GameObject instance : instances) {
			if (isVisible(cam, instance)) {
				modelBatch.render(instance, environment);
				visibleCount ++;
			}
		}
		if (space != null)
			modelBatch.render(space);
		modelBatch.end();

		stringBuilder.setLength(0);
		stringBuilder.append(" FPS: ").append(Gdx.graphics.getFramesPerSecond());
		stringBuilder.append(" Visible: ").append(visibleCount);
		stringBuilder.append(" Selected: ").append(selected);
		label.setText(stringBuilder);
		stage.draw();
	}

	private Vector3 position = new Vector3();
	protected boolean isVisible(final Camera cam, final GameObject instance) {
		instance.transform.getTranslation(position);
    	position.add(instance.center);
    	return cam.frustum.sphereInFrustum(position, instance.radius);
	}

	@Override
	public boolean touchDown (int screenX, int screenY, int pointer, int button) {
		selecting = getObject(screenX, screenY);
		return selecting >= 0;
	}
	
	@Override
	public boolean touchDragged (int screenX, int screenY, int pointer) {
		return selecting >= 0;
	}

	@Override
	public boolean touchUp (int screenX, int screenY, int pointer, int button) {
		if (selecting >= 0) {
			if (selecting == getObject(screenX, screenY))
				setSelected(selecting);
			selecting = -1;
			return true;
		}
		return false;
	}
	
	public void setSelected (int value) {
		if (selected == value) return;
		if (selected >= 0) {
			Material mat = instances.get(selected).materials.get(0);
			mat.clear();
			mat.set(originalMaterial);
		}
		selected = value;
		if (selected >= 0) {
			Material mat = instances.get(selected).materials.get(0);
			originalMaterial.clear();
			originalMaterial.set(mat);
			mat.clear();
			mat.set(selectionMaterial);
		}
	}

	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 GameObject instance = instances.get(i);
			instance.transform.getTranslation(position);
			position.add(instance.center);
			float dist2 = ray.origin.dst2(position);
			if (distance >= 0f && dist2 > distance) continue;
			if (Intersector.intersectRaySphere(ray, position, instance.radius, null)) {
				result = i;
				distance = dist2;
			}
		}
		return result;
	}
	
	@Override
	public void dispose() {
		modelBatch.dispose();
		instances.clear();
		assets.dispose();
	}

	@Override
	public void resize(int width, int height) {
		stage.getViewport().update(width, height, true);
	}

	@Override
	public void pause() {
	}

	@Override
	public void resume() {
	}
}
public class MainGame extends InputAdapter implements ApplicationListener {
    ...
    private int selected = -1, selecting = -1;
    private Material selectionMaterial;
    private Material originalMaterial;
		...
}

我们需要扩展 InputAdapter 以获得输入事件的通知。我们将要实现选择射线功能。接下来,添加了两个整数变量,分别是 selected 和 selecting。我们将使用这些变量在实例数组中存储选中或当前选中的 ModelInstance,-1表示没有选中实例。我还添加了两个材质,我们将使用它通过突出显示所选对象来提供一些视觉反馈。“ selectionMaterial” 将包含高光材质(漫反射颜色) ,“originalMaterial” 将包含原始材质,因此一旦对象不再被选中,我们可以重置它。

    @Override
    public void create () {
        ...
        Gdx.input.setInputProcessor(new InputMultiplexer(this, camController));
        ...
        selectionMaterial = new Material();
        selectionMaterial.set(ColorAttribute.createDiffuse(Color.ORANGE));
        originalMaterial = new Material();
    }

在 create 方法中,我们将输入处理器设置为一个 InputMultiplexer,其中既包含我们正在创建的类,也包含我们之前使用过的 CameraInputController。注意,在 InputMultiplexer 的构造函数中提供它们的顺序非常重要。按照这个顺序,每个事件首先传递给我们的类,只有当该方法返回 false 时,它才会传递给 camController。

在 create 方法中,我们同时构造了 selectionMaterial 和 originalMaterial。为了得到一些高光效果,我们在材质上添加了一个漫反射颜色属性。稍后我们将看到如何使用它来突出显示所选对象。

    @Override
    public void render () {
        ...
        stringBuilder.setLength(0);
        stringBuilder.append(" FPS: ").append(Gdx.graphics.getFramesPerSecond());
        stringBuilder.append(" Visible: ").append(visibleCount);
        stringBuilder.append(" Selected: ").append(selected);
        label.setText(stringBuilder);
        stage.draw();
    }

主要出于调试的目的,我们使用标签显示选定值的值,就像我们也使用帧率数和可见实例数一样。

    @Override
    public boolean touchDown(int screenX, int screenY, int pointer, int button) {
    	selecting = getObject(screenX, screenY);
    	return selecting >= 0;
    }
    
    @Override
    public boolean touchDragged(int screenX, int screenY, int pointer) {
    	return selecting >= 0;
    }
    
    @Override
    public boolean touchUp(int screenX, int screenY, int pointer, int button) {
    	if (selecting >= 0) {
    		if (selecting == getObject(screenX, screenY))
    			setSelected(selecting);
    		selecting = -1;
    		return true;
    	}
    	return false;    	
    }

在这里,我们重新了 InputAdapter 类的 touchDragged 和 touchup 方法,当我们在这些方法中返回 false 时,事件将被传递给相机控制器。

在 touchDown 方法中,我们使用 getObject 方法设置 selecting 变量。我们以屏幕坐标(x 和 y)作为参数提供的 getObject (x,y)方法将返回实例数组中的索引,以指示位于指定屏幕坐标的实例。当没有对象位于指定的屏幕坐标时,我们将使其返回 -1。我们很快就会看到这个方法的实现。如果没有选择对象,我们将在 touchDown 方法中返回 false,这样我们仍然可以控制 camController。

在 touchDrag 中,我们只是简单地返回当前是否选择对象。

在 touchUp 方法中,我们还返回当前是否选择对象。如果是这样,我们将检查当前鼠标/触摸位置的对象是否与开始选择时的对象相同。如果它们是相同的(例如用户点击一个对象) ,那么当调用“ setSelected”方法来更新(并突出显示)新选择的对象时。

	public void setSelected (int value) {
		if (selected == value) return;
		if (selected >= 0) {
			Material mat = instances.get(selected).materials.get(0);
			mat.clear();
			mat.set(originalMaterial);
		}
		selected = value;
		if (selected >= 0) {
			Material mat = instances.get(selected).materials.get(0);
			originalMaterial.clear();
			originalMaterial.set(mat);
			mat.clear();
			mat.set(selectionMaterial);
		}
	}

如果指定的值与当前选定的值相同,则不需要继续。否则,如果有一个对象以前选择,我们将需要恢复它的材质。在这种情况下,我们假设对象只有一个材质,我们通过索引访问它。我们使用 clear ()方法删除高光颜色,并使用 set (originalMaterial)方法恢复原始材质。

接下来,我们将选定变量的值更新为新的指定值,如果该值有效( >= 0 ) ,那么我们将突出显示该特定对象。为此,我们获取对象的第一个材质,其中我们还假设对象只有一个材质,并通过索引选择它。接下来,我们使用‘ clear ()’方法删除 originalMaterial 的所有现有属性,并将所选对象的所有属性添加到它。接下来,我们使用 selectionMaterial 对材质本身进行同样的操作。这将导致材质只包含漫反射颜色,就像我们在“ create ()”方法中设置的那样。

到目前为止所有的改变都应该是直截了当的。让我们来看看 getObject (x,y)’方法。

	public int getObject (int screenX, int screenY) {
		Ray ray = cam.getPickRay(screenX, screenY);
		...
	}

我们首先从摄像机中获取取Ray。什么是Ray?对于每一个屏幕坐标,都有无限数量的三维坐标。例如,如果你有一个坐标(x = 0,y = 0,z = 0)位于屏幕中心的摄像机,正对着 -z (这是很常见的) ,那么一个位于(x = 0,y = 0,z =-10)的物体将被绘制在与一个位于(x = 0,y = 0,z =-20)的物体相同的位置。因此,屏幕中心的屏幕坐标既代表坐标(0,0,-10)和(0,0,-20) ,也代表中间的所有坐标,等等。您可以将其看作是通过指定屏幕坐标的所有可能的三维坐标的一条直线。这就是所谓的拾取光线Ray,并由一个起点称为原点(第一个可见点,位于相机的近平面)和方向矢量指向远离相机表示。从数学上可以看出: f(t) = origin + t * direction

因此,基本上我们需要检查哪个物体与光线相交,以找到在指定的屏幕坐标物体。然而,多个物体与射线相交是有可能的(这取决于相机的角度)。在这种情况下,我们必须决定选择哪个物体。由于距离相机较近的物体比距离相机较远的物体更容易被看见,我们将使用物体到相机的距离(射线原点)来做出这个决定。

	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 GameObject instance = instances.get(i);

			instance.transform.getTranslation(position);
			position.add(instance.center);

			float dist2 = ray.origin.dst2(position);
			if (distance >= 0f && dist2 > distance)
				continue;

			if (Intersector.intersectRaySphere(ray, position, instance.radius, null)) {
				result = i;
				distance = dist2;
			}
		}

		return result;
	}

在得到 ray 之后,我们添加两个变量。一个用于存储当前最接近相机的物体,另一个用于存储该物体到相机的距离。我们将这些变量设置为 -1,以表明当前没有最接近的对象。接下来,我们迭代所有对象并获取当前实例。然后我们获取对象的位置并偏移它,这样我们就得到了对象的中心,就像我们对于截锥剔除所做的一样。

接下来我们计算物体(中心)和射线原点(最接近相机)之间的平方距离。请注意,计算平方距离比实际距离稍微快一点(因为计算使用的是平方值 a^2 + b^2 = c^2 ,所以在可能的情况下,我们尝试使用平方距离。如果这个对象比当前最近的对象离得更远,那么就没有必要继续计算,因此我们继续下一个对象。

接下来我们需要检查物体是否与光线相交。幸运的是,libgdx 包含一个很好的帮助类,用于这种计算,称为“ Intersector”。就像我们对于截锥剔除所做的一样,我们检查边界球。虽然这可能会导致更多的误报,但是它非常快,并且在对象和/或摄像机旋转时也能工作。稍后我们将快速浏览一下更精确的方法。

如果射线和边界球相交,我们必须相应地更新“结果”和“距离”变量。最后我们返回最接近相机的交叉物体的索引。

让我们来看看这些变化,看看它是否有效:

image.png

image.png

看起来效果不错。例如,如果您尝试选择远处的入侵者,您可能会注意到使用边界球的不准确性。但总的来说,这正是我们所期望的。

当一个对象被选中时,我们可以使用射线来移动对象。实现这一点相对比较容易。然而,使用2 d 屏幕在3 d 世界中移动一个物体需要你把移动限制在一个单一的平面。由于我们所有的对象都位于 XZ 平面(y = 0) ,我们将使用该平面来实现移动对象。为此,我们只需要更新 touchdrag 方法:

    @Override
    public boolean touchDragged(int screenX, int screenY, int pointer) {
    	if (selecting < 0) 
    		return false;
    	if (selected == selecting) {
    		Ray ray = cam.getPickRay(screenX, screenY);
    		final float distance = -ray.origin.y / ray.direction.y;
    		position.set(ray.direction).scl(distance).add(ray.origin);
    		instances.get(selected).transform.setTranslation(position);
    	}
    	return true;
    }

就像之前一样,如果我们没有选择一个对象,我们返回 false,如果我们选择一个对象,我们返回 true。但是现在,如果我们选择一个对象,它和之前选择的是同一个对象,我们会移动它。也就是说: 用户必须点击选择,然后可以移动对象。

为了真正移动它,我们首先从摄像机中得到射线。接下来我们计算从射线原点到 XZ 平面上位置的距离(其中 y = 0)。记住:

  • f(t) = origin + t * direction,
  • 对于 y 分量: y = origin.y + t * direction.y, :
  • 或者因为我们需要 y = 0,那就是:0 = origin.y + t * direction.y
  • 从两个方向减去 t * direction.y,我们得到: -t * direction.y = origin.y.
  • 这个方程乘以-1,我们得到: t * direction.y = -origin.y.
  • 我们需要 t 的值,所以我们用 direction 来除以:t = -origin.y / direction.y

现在我们知道了距离,我们可以使用 direction * distance + origin 得到光线上的位置,并相应地设置所选对象的平移。让我们运行它,看看它是如何工作的:

image.png

我们现在可以与3d 物体进行交互。当然你不必限制 ZX (y = 0)平面,你可以使用任何你想要的平面。使用射线与3 d 物体进行交互基本上是一个数学问题。

这让我们回到 getObject (x,y)方法的准确性,它对于远方的入侵者来说似乎相当不准确。我们也可以用一些数学来解决这个问题。这种不准确是由于物体靠近摄像机时,它的包围球体与拾取光线相交,而实际的视觉形状却不是这样。当然,我们可以通过使用更精确的形状来解决这个问题(我们可能会在以后的教程中研究这个问题) ,但是在我们的例子中,这样做有点过火了。我们可以用物体中心到射线的距离来决定是否应该选择,而不是用到相机的距离。

为此,我们需要找到光线上离物体中心最近的点。这可以用一种叫做矢量投影的方法来完成。我不会详细介绍它背后的数学原理(尽管如果你想使用它的话,我建议你读一读)。然而,这个计算过程非常接近“ Intersector.insersectRaySphere (...)”的实现,因此我们不妨完成自己的计算,避免重复计算。下面是新的 getObject (x,y)方法:

	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 GameObject instance = instances.get(i);

			instance.transform.getTranslation(position);
			position.add(instance.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)
				continue;

			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);
			if (distance >= 0f && dist2 > distance) 
				continue;

			if (dist2 <= instance.radius * instance.radius) {
				result = i;
				distance = dist2;
			}
		}
		return result;
	}

在这里,我们基本上把物体的中心投射到光线上,从而给出(到)最接近物体的光线上的点的距离。然后我们检查该点和物体中心之间的平方距离,并用它来决定是否选择该物体。我不会详细介绍数学(有很多网站描述矢量投影)。如果您运行这个命令,您将注意到它比之前选择对象的方法要精确得多。