Libgdx New 3D API 教程之 -- 与三维物体的交互

This blog is a chinese version of xoppa’s Libgdx new 3D api tutorial. For English version, please refer to >>LINK<<

在之前的教程里,我们已经了解过,如何判断在相机的视界中,一个物体是否可见。今天,我们继续学习,如何判断一个物体是否被click(点击)/touch(触摸),并且如何在3维场景中,拖动物体。

如果你还没看过之前的教程,我建议在阅读本教程前,先去看一看上一篇,因为这里会以之前的代码为基础。和以往一样,源码,资源,等可以运行的例子,都可以在github上面找到。

在我们真正开始与物体交互之前,我们要先接收用户在屏幕上触摸,与拖动操作的事件。这个,可以通过实现InputListener接口,或扩展InputAdapter类来达成。我们采用第二种方式,这样,我们可以实现我们需要的方法,其他我们没有override的方法,InputAdapter的默认方式,会简单的返回false。如果你还没有了解过这些,或者想知道多一点,可以读下这篇文章.

先提供完整的代码,然后我们看看改了什么:

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

    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 ModelInstance ship;
    protected ModelInstance space;

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

    private int visibleCount;
    private Vector3 position = new Vector3();

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

    @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 (final 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();
    }

    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 RayPickingTest extends InputAdapter implements ApplicationListener {
    ...
    private int selected = -1, selecting = -1;
    private Material selectionMaterial;
    private Material originalMaterial;

一开始就说到,我们需要继承InputAdapter来获取输入事件。我给这个类改了个名字,因为Ray Picking就是我们要实现的。接下来,添加了两个integer变量:selected & selecting. 我们将用这两个变量来记录,在全部对象数组中,被选中的,和正被选择中的物体对象ModelInstance。-1表示,没有被选中的物体。我还添加了两程材质,我们要通过不同的材质来高亮显示被选中的物体。seselectionMaterial是高亮材质,而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做Input processor(输入处理器),这个InputMultiplexer包含了我们上面创建的类,还有我们以前用过的CameraInputController。注意,添加的顺序是有讲就的,上面这样的顺序,输入的事件将优先传给我们的类进行处理,而仅当我们的类返回false时,才会传递给camController处理。这样就能做成,比如说:没有点击到任何3d物体时,操作就作用到了camera。

在created方法中,我们还创建了两个材质:selectionMaterial & originalMaterial。为了有一些高亮的效果,我们给selectionMaterial添加一些漫反射光。过一会儿,就可以看到我们如何使用这个,给物体加高亮了!

@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();
}

调试用的东东,我们要显示选中物体的编号,好像上一章显示出fps与可见物体数那样。

@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类里面的touchDown, touchDragged 和 touchUp方法。记得,如果这些方法,返回了false,事件就会被传递给camera controller。

在touchDown方法里,我们调用了getObject方法,来返回选中的对象。这个方法接收x, y两个参数,表示触摸在屏幕上的坐标,返回值表示被选中物体在数组中的位置。如果没有选中任何的物体,就返回-1,而touchDown方法需要返回false,从而将这一次触摸事件传送到下一个camera controller。稍后我们就要实现这个方法。

在touchDragged方法中,我们只返回当前是否有选中一个物体。

在touchUp方法里,我们也返回了当前是否有选中一个物体。如果有选中,我们就来判断该物体与之一开始选中的物体是否为同一个。如果相同,我们就调用setSelected方法,去更新它。

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

如果当前选中的物体与原本的一样,则无需做任何改动。否则,若之前有已选中物体,我们需要还原它的的材质。在这个示例中,我们假设物体只有一种材质,而我们可以通过index来访问它。我们使用clear方法移除高亮材质,并使用原始材质为其贴图。
接着,当这个数合法时(>=0),更新selected变量为最新选中的值,然后高亮相应的对象。这里,我们得到物体的第一个材质,这里我们假设物体只有一个材质,并且通过index直接获得。接下来,通过clear()方法清空当前originalMaterial,并将物体的原始材质,通过set方法保存到originalMaterial。再清空物体的材质mat,并赋值selectionMaterial给mat。这样会使得物体只有一个漫反射高的效果。
上面所有的改动,到这里都很直接了当,但真正好玩的地方,应该是getObject(x,y)方法里。让我们看看:

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

从camera中得到Pick Ray开始,问题来了,Pick Ray是啥?对于每一个屏幕坐标来说,有无限的3维坐标。比如,有一个camera,以x=0,y=0,z=0为中心,看向-Z,然后有对象一在坐标x=0,y=0,z=-10,对象二在坐标x=0,y=0,z=-20。所以,屏幕的中心的坐标与x=0,y=0,z=-10,x=0,y=0,z=-20这两个坐标,以及中间全部的坐标都重合了。你可以想象,对于现在这个屏幕坐标而主,这就是一条直线。这就是我们所说的Pick Ray了,由一个起始点(在相机近切面的可见点),与一个表示方向的向量。数学上来表示就是f(t) = origin + t * direction.

本质上,我们需要判断哪一个对象与Pick Ray相交,来找到对应现在这个屏幕坐标,被选中的物体。然而,当camera角度不同时,有可能多个物体都与这条pick ray相交了。此时,我们需要对这些物体进行选择。相对于相机较远的物体,近相机的物体才是可见的,我们可以通过距离来做这个判断。

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,表示当前还没有找到这个物体。接着,通过迭代全部的对象,来取得当前物体实例。然后像以前一样,取得物体的位置,和他的中心。

然后我们计算物体中心到Ray起点的距离。注意,这里只计算平方距离a^2 + b^2 = c^2,而不是实际距离,因为只计算平方值,计算速度会稍微快一些。所以我们也尽量只使用平方距离。如果这个对象相对于当前最近的对象远,那就没必要再继续算计,继续下一个对象。

接着,判断这个对象是否与Ray相交,Libgdx有一个不错的工具类,来处理这种计算:Intersector。像我们在Frustum culling一样,我们通过物体参考球(一个刚刚好包住物体的球)体来做判断。尽管这有一些负面影响,但速度很快,而且,好处是,当物体或相机旋转的时候,也一样好用。迟点我们来简单介绍一种更精确的判断方法。

如果Ray与物体的参考球有相交,我们就更新result和distance的值。最后返回这个距离相机最近的对象的index。

Let’s check out these changes and see if it works:
raypicking1
Well that seems to work quite well. If you try to select for example an invader further away, you might notice the inaccuracy of using the bounding sphere. But overall it does what we expected.

When an object is selected, we can use the ray to move the object. It is relatively easy to implement this. However, moving an object around in a 3d world using a 2d screen requires that you for example restrict the movement to a single plane. Since all our objects are located at the XZ plane (y=0), we’ll use that plane to implement moving the objects. For this, we only have to update the ‘touchDragged’ method:

1
2
3
4
5
6
7
8
9
10
11
12
@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;
}
View full source on github

Just like before, if we aren’t selecting an object we return false and if we are selecting an object we return true. But now, if we are selecting an object and it is the same object as previously selected we’ll move it around. That is: the user have to tap to select and can then move the object.

To actually move it around, we first get the ray from the camera. Next we calculate the distance from the origin of the ray to the location on the XZ plane (where y=0). Remember:
f(t) = origin + t * direction, for the y-component this is:
y = origin.y + t * direction.y, or since we want y=0, it is:
0 = origin.y + t * direction.y. Substract t * direction.y from both sides and we get:
-t * direction.y = origin.y. Negate the equation and we get:
t * direction.y = -origin.y. We want the value of ‘t’ so let’s divide by ‘direction.y’:
t = -origin.y / direction.y.

Now we know the distance, we can get the location on the ray using direction * distance + origin and set the translation of the selected object accordingly. Let’s run it and see how it works:
raypicking2
We are now able to interact with 3d objects. Of course you don’t have to restrict to ZX (y=0) plane, you can use any plane you want. It is basically a matter of math to use the ray to interact with 3d object.

This brings us back to the accuracy of the getObject(x, y) method, which seems to be quite inaccurate for the distant invaders. We can also solve this using some math. The inaccuracy is caused by an object closer to the camera of which it’s bounding sphere intersects with the pick ray, while it’s actual visual shape doesn’t. Of course we could solve this by using a more accurate shape (and we might look into that in a future tutorial), but in our case that would be an overkill. Instead of using the distance to the camera, we can use the distance of the center of the object to the ray to decide whether or not should be chosen.

For that we need to find the point on the ray closest to the center of the object. This can be done using something called vector projection. I will not go into details about the math behind it (although I would suggest to read into it if you want to use it). However, the calculation is so close to the implementation of ‘Intersector.insersectRaySphere(…)’, that we might as well do the complete calculation our self and avoid duplicate calculations. So here’s the new

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值