サイトのトップへ戻る

libGDX ドキュメント 日本語訳

サイト内検索

3D オブジェクトを操作する

前回のチュートリアルでは、3Dオブジェクトがカメラに写っているかどうかを確認する方法について見てきました。 このチュートリアルでは、どのオブジェクトがタッチ/クリックされているかを確認し、3D世界内でそれらオブジェクトをドラッグして操作する方法について説明します。

このチュートリアルでは、前回のチュートリアルのコードを基にして説明を行います。 そのため、まだの場合はこのチュートリアルを読む前に前回のチュートリアルを読んでおくことをお勧めします。 いつものように、このチュートリアルのソースと素材と実行可能なテストは githubにあります。

実際にオブジェクトの操作を開始する前に、ユーザーが画面上でタッチやドラッグした時にそのイベントを受け取る必要があります。 これは、InputListener インタフェースを実装したり、InputAdapater クラスを継承したりすることで行えます。 今回は後者の方法を取ります。そのため、実際に必要なメソッドを実装すれば良いだけなので、上書きしないメソッド(入力イベント)についてはInputAdapterはfalseを返すだけです。 入力の制御についてよく知らない場合や入力についてもっと知りたい場合は、 この wiki ページを確認すると良いでしょう。

参考までに、以下が変更済みコードの全てです。以降で変更内容について見ていきます:


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 () {
    }
}

githubにある全ソースコードを見てください

かなり変更されているので、それらについて説明していきましょう:


public class RayPickingTest extends InputAdapter implements ApplicationListener {
    ...
    private int selected = -1, selecting = -1;
    private Material selectionMaterial;
    private Material originalMaterial;

説明したように、入力イベントの通知を受け取るためにはInputAdapter を継承する必要があります。 レイピッキングを実装するので、クラス名も変更しています。 次に、 'selected' と'selecting'という名前の二つのint型変数を追加します。 これらの変数を使って、インスタンス配列内のどのModelInstance が選択されたかや、現在どの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 メソッド内で、作成したこのクラスと前回使用したCameraInputController の両方を含んだInputMultiplexer を、入力プロセッサーに設定します。 これらをInputMultiplexer のコンストラクタに渡す順番が重要なので、注意してください。 この順番の場合、各イベントは最初にこのクラスへ渡されて、受け取ったメソッドがfalseを返した場合にのみ、次のcamControllerへとイベントが渡されます。 これにより、例えば3Dオブジェクトに触れていない場合はカメラの操作を行う、といったことができるようになります。

またcreate メソッド内で、 '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();
    }

主にデバッグ目的のために、labelを使用してselected 変数の値を表示します。 以前に1秒間のフレーム数と写っているインスタンス数を画面に表示しましたが、それと同じことをやっています。


    @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を戻り値として返した場合、このイベントはカメラコントローラーへ渡されるので覚えておいてください。

touchDown メソッド内では、 'getObject'メソッドを使って'selecting'変数を設定します。 この getObject(x, y) メソッドにはスクリーン座標(x と y)を引数として渡します。 そうすると、インスタンス配列内における、指定されたスクリーン座標にあるインスタンスを示すインデックスが戻り値として返ります。 指定されたスクリーン座標にオブジェクトがない場合、-1が戻り値として返ります。 さっそくこのメソッドの実装を見ていきましょう。 オブジェクトが選択されていない場合、touchDown メソッド内ではfalse を返すので、そのままcamControllerの制御を行うことができます。

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

渡された引数として渡された値が現在選択されている値と同じだった場合、処理を実行する必要はありません。 違っていた場合、以前に選択しているオブジェクトがあればそのオブジェクトのマテリアルを元に戻す必要があります。 今回は、オブジェクトは一つのマテリアルのみを持っており、インデックスを使ってマテリアルにアクセスするものとします。 clear()メソッドを使って強調色を削除し、set(originalMaterial) メソッドを使って元のマテリアルを復元します。

次に、selected 変数に新しく引数として渡された値を代入し、それが有効な値(>= 0)だった場合は該当するオブジェクトを強調表示します。 これを行うために、オブジェクトの一つ目のマテリアルを取得します。ここでもオブジェクトは一つのマテリアルのみを持っており、インデックスを使ってマテリアルにアクセスするものとします。 次に'clear()' メソッドを使ってoriginalMaterial の既存の属性を全て削除し、 選択しているオブジェクトの全ての属性をoriginalMaterial へ追加します。 次に、selectionMaterialを使ってマテリアル自体でも同じ処理を行います。 'create()' メソッド内で設定したように、マテリアルがディフューズ色のみを含むようにします。

今までの全ての変更は本当に分かりやすいものでした。 'getObject(x,y)' メソッドが、実際に魔法を唱える場所になります。 それでは 'getObject(x,y)' について見てみましょう。


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

まずはカメラからrayを取得することから始めます。 ここで、rayを取得するというのは何なのか、とあなたは質問するかもしれません。 全ての画面座標に対して、無限の数の3D座標が存在します。 例えば、カメラが-Zの方向を向いた状態(非常に一般的な状態)での画面中央の座標(x=0,y=0,z=0)を捉えている場合、(x=0,y=0,z=-10)座標のオブジェクトは(x=0,y=0,z=-20)座標のオブジェクトと同じ位置に描写されます。 したがって画面中央のスクリーン座標は、 (0,0,-10)座標と (0,0,-20) 座標の両方、およびその間にある全ての座標などを表します。 これは、指定されたスクリーン座標になりうる全ての3D座標を通る直線として考えることができます。 これはray を取得すると呼ばれ、原点(カメラのnear平面に位置する最初の可視点)と呼ばれる開始点と、カメラから離れる位置を指す方向ベクトルによって表されます。 数学的には、これは: f(t) = origin + t * directionとして見ることができます。

そのため基本的に、指定したスクリーン座標にあるオブジェクトを見つけるためには、rayと交差しているオブジェクトがどれなのかを確認する必要があります。 しかし、複数のオブジェクトがrayと交差している可能性があります(例えば、カメラのアングルによってその数は変わります)。 その場合、複数オブジェクトの中からどれか一つを選ぶ必要があります。 カメラの遠くにあるオブジェクトよりも近くにあるオブジェクトの方が目に入る可能性が高いので、オブジェクトとカメラの距離(ray origin)を使ってこれを決定します。


    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の原点(カメラに最も近い位置)との距離の2乗を計算します、 距離の2乗を計算する際の処理速度は、実際の距離を計算する際の処理速度よりも少し速いので覚えておいてください(a^2 + b^2 = c^2の値をそのまま使用して平方の計算を省いているため)。そのため、可能であれば距離の2乗を使うようにします。このオブジェクトが現時点で最もカメラに近いオブジェクトよりも遠くにある場合は、計算を続行する必要がないので、continueで抜けて次のオブジェクトへと進みます。

次に、このオブジェクトがrayと交差しているかどうかを確認する必要があります。 幸いlibgdx には、そういった計算を助けてくれる'Intersector'という名前の素晴らしいクラスがあります。 視錐台カリングでやったのと同じ様に、バウンディングスフィアに対して確認処理を行います。 これは偽陽性による誤検知を引き起こす可能性がありますが、非常に高速でオブジェクトやカメラが回転した時でも動作します。 より正確な方法については後ほど簡単に見ていきます。

ray とバウンディングスフィアが交差している場合、それに応じて 'result' 変数と 'distance' 変数を更新する必要があります。 そして最後に、rayと交差しているオブジェクトの中でカメラに最も近いもののインデックスを戻り値として返します。

それでは、これらの変更を確認してちゃんと動作するか見てみましょう。:

raypicking1

なんと、かなり上手く動作しているように見えます。 遠くにあるインベーダーを選択しようとすると、バウンディングスフィアを使用する場合の不正確さに気づくかもしれません。 ですが、全体的には期待通りの動作です

オブジェクトを選択した時に、rayを使ってオブジェクトを動かすことができます。 これを実装するのは比較的簡単です。 しかし、2D画面を使って3D世界内にあるオブジェクトを移動させるには、例えば、移動するのを一つの平面上に制限する必要があります。 全てのオブジェクトは XZ 平面上(y=0)に置かれているので、XZ 平面を使ってオブジェクトの移動を実装します。 そのためには、 'touchDragged' メソッドを更新すればいいだけです:


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

githubにある全ソースコードを見てください

以前と同じ様に、オブジェクトを選択していない場合はfalseを返し、オブジェクトを選択している場合はtrueを返します。 しかし今回は、オブジェクトを選択していてそれが前回選択したオブジェクトと同じ場合に、それを移動させることができます。 つまり: ユーザーがオブジェクトを選択するにはそれをタップする必要があり、そうすることでオブジェクトを移動できます。

実際にオブジェクトを移動させるには、まずはカメラからrayを取得します。 次に、rayの原点からXZ 平面上 (y=0)までの位置を計算します。 以下の流れを覚えておいてください:

  • 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.

これで距離が分かったので、direction * distance + originを使ってray上の位置を取得し、それに応じて選択したオブジェクトの移動をすることができます。 それではこれを実行して、どのように動作するか見てみましょう:

raypicking2

これで、3Dオブジェクトを操作することができます。 もちろん ZX (y=0) 平面に限定する必要はなく、任意の平面を使用することができます。 rayを使用して3Dオブジェクトを操作するのは、基本的には数学の問題になります。

ここで、getObject(x, y) メソッドの正確性の話に戻りましょう。 この方法だと遠くのインベーダーを選択する場合には非常に不正確です。 数学を使ってこれを解決することができます。 この不正確さは、選択したオブジェクトよりもカメラに近い位置にあるオブジェクトが原因で引き起こされます。 より近いオブジェクトのバウンディングスフィアがrayの取得と交差してしまうのです。 ですが、実際の見た目形状はrayの取得と交差していません。 もちろん、より正確な形状を使うことでこれを解決することもできますが(将来的にはこの方法に関するチュートリアルをやるかもしれません)、今回の場合それは過剰なものになってしまいます。 カメラまでの距離を使用する代わりに、オブジェクトの中心からrayまでの距離を使って、そのオブジェクトを選択すべきかどうかを判断することができます。

そのためには、オブジェクトの中心に最も近いray上の点を見つける必要があります。 これはベクトル射影と呼ばれるものを使って行えます。 その裏側の数学部分については詳しく触れません (ですが、それを使ってみたいのであれば読んでみることをお勧めします)。 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. それでは、以下が新しい 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;
    }

githubにある全ソースコードを見てください

上記コードでは、基本的にはオブジェクトの中心をrayに投影し、これによりそのオブジェクトに最も近いray上の点(までの距離)が得られます。 そしてその点とオブジェクトの中心との間の距離の2乗を確認し、その値を使ってオブジェクトを選択するかどうかを判断します。 数学部分については詳しく触れません(ベクトル射影について説明しているサイトは他にたくさんあるので)。 これを実行すると、先ほどのオブジェクト選択方法よりもはるかに正確であることが分かるでしょう。

次のチュートリアルでは、衝突形状を使ったより正確な方法について見ていきます。