サイトのトップへ戻る

libGDX ドキュメント 日本語訳

サイト内検索

libGdxでの3D視錐台カリング

3D シーンを描写する際、実際に視界に入るオブジェクトの数はシーン内のオブジェクト総数よりかなり少なくなることがよくあります。 表示されないオブジェクトまで含めた全てのオブジェクトを描写すると、貴重なGPU時間が無駄になってゲーム速度が低下する可能性があります。 理想を言えば、実際にカメラに写るオブジェクトのみを描写し、その他のオブジェクト(例えばカメラの背後にあるような)は全て無視したいことでしょう。 これは視錐台カリングとして知られており、これを行うにはいくつかの方法があります。 このチュートリアルでは、LibGDXの3D apiを使って視錐台カリングを行う方法の非常に基本的な点について説明します。

実際に視錐台カリングを開始する前に、この技術を適用するシーンが必要です。 したがって、以前のチュートリアル: libGDXで3Dシーンを読み込むのシーンとコードを使用します。 あなたはこのチュートリアルを既に読んでいるものとして説明を行うので、コードの詳細については説明しません。

視錐台カリングの実装によってどれくらいパフォーマンスが上がったのかを目に見える形で表すと便利かもしれません。 そこで、描写されているオブジェクトの数を示すLabelを追加し、ついでに1秒あたりのフレーム数も追加してみましょう。 参考のために、完全なコードを以下に示します。変更点については以降で説明します:


public class FrustumCullingTest implements ApplicationListener {
    protected PerspectiveCamera cam;
    protected CameraInputController camController;
    protected ModelBatch modelBatch;
    protected AssetManager assets;
    protected Array<ModelInstance> instances = new Array<ModelInstance>();
    protected Environment environment;
    protected boolean loading;

    protected Array blocks = new Array();
    protected Array invaders = new Array();
    protected ModelInstance ship;
    protected ModelInstance space;

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

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

        assets = new AssetManager();
        assets.load("data"+"/invaderscene.g3db", Model.class);
        loading = true;
    }

    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;
            ModelInstance instance = new ModelInstance(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(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

        modelBatch.begin(cam);
        visibleCount = 0;
        for (final ModelInstance 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);
        label.setText(stringBuilder);
        stage.draw();
    }

    protected boolean isVisible(final Camera cam, final ModelInstance instance) {
        return true; // FIXME: Implement frustum culling
    }

    @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を参照してください

少しだけ変更点があるので、それについて説明しましょう。まず最初に、Stageと Labelと BitmapFont と StringBuilderを追加しています;


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

次に、create メソッド内でこれらのメンバを初期化します:


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

doneLoading メソッド内では、ModelInstanceを作成するために便利なメソッドを使用しているので覚えておいてください。 三つ目の引数(mergeTransform)は、以前に手動で作成した時と全く同じことをしています。 つまり、ModelInstance の変形状態を設定してNodeの変形状態 をリセットしています。

Stage (scene2d)の使い方があまり分からない場合、このチュートリアル通りに記述することをお勧めします。 ここで説明しているのがUIを実装するのに最適な方法だからです。 次は、3D シーン上に実際にUI を描写しているrender メソッドを見てみましょう:


    private int visibleCount;
    @Override
    public void render () {
        ...
        modelBatch.begin(cam);
        visibleCount = 0;
        for (final ModelInstance 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);
        label.setText(stringBuilder);
        stage.draw();
    }

全てのインスタンスを一度に描写するのではなく、まずは各インスタンスがカメラの視界内にあるかどうかを確認し、視界内にある場合のみ描写を行います。 また判定時にvisibleCount のカウントを増やし、実際に描写されるインスタンスの数を追えるようにします。 space ModelInstance はカウントに含めませんが、常に表示されるので注意してください。 視界に関わらず常に表示され続けるため、このようにしています。

次にStringBuilder を使って、1秒当たりのフレーム数と実際に表示されるインスタンスの数 (spaceは除く)を含んだ文字列を作成します。 Labelにテキストを設定し、最後にstageを描写します。 render メソッド内で文字列を連結する際には、StringBuilder を使用することを強くお勧めします。 StringBuilder はガベージが少ないため、ガベージコレクションが原因の一時的な中断がほとんど発生しません。


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

resize メソッド内では、stageのビューポートを更新する必要があります。 最後のBoolean 引数で原点を左下隅に設定し、Label はその原点位置に表示されます。

そしてisVisible メソッドが魔法を唱える場所で、ModelInstance を表示するかどうかはここで決定します。 今のところこれはただのメソッドスタブで、常に trueを返します:


    protected boolean isVisible(final Camera cam, final ModelInstance instance) {
        return true; // FIXME: Implement frustum culling
    }

それでは、これを実行して実際に見てみましょう:

frustumculling1

ご覧の通り、37個のオブジェクトが表示されています。 船が1個、ブロックが6個、インベーダーが30個(そしてカウントされていない 宇宙空間 が1個)。 カメラを移動させると、全てのオブジェクトが実際に表示さているかどうかに関わらず、オブジェクト数は常に37のままです。 それでは、視錐台カリングを実装する時が来ました。

視錐台という用語についてあまり詳しくない場合:視錐台とは、カメラの先端とそのカメラで見ることができる全てのものを含んだ範囲で形成される、3次元空間におけるピラミッドのような形状として見ることができます。 このページに、視錐台とカメラに関する良い記事があるので、 カメラの視錐台について理解したりイメージしたりするのが難しい場合は読んでおくことをお勧めします。 実際のところ、視錐台とは六つの平面で構成されています。それらの名前は、left平面、right平面、top平面、bottom平面、 near 平面、far 平面です。 オブジェクトがこれら六つの平面の中にある場合はカメラに写りますが、これら六つの平面(のうちいずれか)の外にある場合はカメラに写りません。

幸い libGDX には、オブジェクトが視錐台の内側にあるかどうかを確認するために非常に簡単なメソッドが用意されています。 それでは、そのチェック処理を実装してみましょう。:


    private Vector3 position = new Vector3();
    protected boolean isVisible(final Camera cam, final ModelInstance instance) {
        instance.transform.getTranslation(position);
        return cam.frustum.pointInFrustum(position);
    }

githubを参照してください

上記コードでは、Vector3 を追加して位置情報を保持しています。 isVisible メソッド内でModelInstance の位置情報を取得し、次にその位置が視錐台の内側にあるかどうかを確認します。 それでは上手くいくか見てみましょう:

frustumculling2

まあ、正しく表示されているように見えます。しかし注意深く見てみると、カメラを動かしたり回転させたりした時に、オブジェクトが出現したり消えたりすることに気づくでしょう。 カリング処理があまりにも早く行われてしまっているのです。 これは、instance.transform.getTranslation(position)を使って取得した位置情報が、ModelInstanceの中心を示しているためです。 インスタンスの中心が視錐台の内側にないとしても、必ずしもインスタンスが視錐台の内側にないというわけではありません。 例えば、船の中心がカメラに写っていない場合でも、まだ船の右翼が部分的にカメラに写っている可能性があるのです。

これを解決するために、カリング処理を行う前にそのオブジェクトが本当にカメラに写っていないかを確認する必要があります。 しかしながら、オブジェクトの全ての各頂点(点)を視錐台と照合するのは非常に負荷が高くなり、おそらく逆効果です。 オブジェクトの容積を使って、そのオブジェクトがカメラの視界内にあるかどうかを計算できます。 これによりオブジェクトが視錐台内にある場合は常に描写を行うようにできますが、偽陽性による誤検知(オブジェクトは表示されていないが、その容積は視錐台内にある)を引き起こす可能性があります。

これを実装するには、ModelInstanceの容積をModelInstance内に保存する必要があります。 ModelInstanceを拡張して必要なコンストラクタ(のみ)を実装することで簡単にできます。:


public class FrustumCullingTest implements ApplicationListener {
    public static class GameObject extends ModelInstance {
        public final Vector3 center = new Vector3();
        public final Vector3 dimensions = new Vector3();

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

上記コードでは、ModelInstanceのBoundingBox(バウンディングボックス)を計算しています。 BoundingBoxの中心は、モデルの原点(=モデリングソフト上でのモデルの中心)とは一致しない可能性があります。 したがって、その値をVector3型のメンバcenterに保存します。 次にModelInstanceの容積をdimensionsメンバに保存します。 使用しているBoundingBoxは静的メンバなので、作成する全てのGameObjectで再利用されるということに注意してください。

ModelInstance を拡張して GameObjectを定義したので、ModelInstance が使用されている全ての箇所を GameObjectへ置き換える必要があります。 その変更作業については特に説明しませんが、置き換えは簡単にできるでしょう

それでは、 isVisible メソッドを更新して dimensionsを追加してみましょう。:


    protected boolean isVisible(final Camera cam, final GameObject instance) {
        instance.transform.getTranslation(position);
        position.add(instance.center);
        return cam.frustum.boundsInFrustum(position, instance.dimensions);
    }

githubを参照してください

これを実行すると、オブジェクトが出現したり消えたりすることはもうなくなり、素晴らしい視錐台カリングになっていることが分かるでしょう。 しかし、全ての場合において正確に動作するわけではありません。例えばGameObjectを回転させた時、そのdimensionsは回転しません。 これを解決するための最も簡単な(そしておそらく最も高速な)方法は、dimensionsの考えうる全ての回転を含んだ球体(dimensionsの中心を回る)を考えることです。 それでは、GameObjectにこれを追加してみましょう:


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

上記コードでは、単純に容積の長さの半分の値を取得して、それをradius(半径)として設定しています。 それでは、視錐台カリング処理を更新しましょう:


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

githubを参照してください

上記コードでは、視錐台に対して確認を行う便利なメソッドsphereInFrustumを使っています。 半径に対して確認処理を行うと少し速くなりますが、偽陽性による誤検知が発生する可能性が高くなります。

次のチュートリアルでは、この知識を使って3Dオブジェクトを操作します。