八、视截体剔除

在渲染 3D 场景时,可见对象的实际数量通常比场景中的对象总数少很多。渲染所有对象,即使是那些不可见的对象,也会浪费宝贵的 GPU 时间并降低游戏速度。理想情况下,您只想渲染那些对相机实际可见的对象,而忽略所有其他对象,例如在相机后面的对象。这被称为视截体剔除,有几种方法可以实现这一点。本教程将向您展示如何使用 LibGDX 的 3D api 完成视截体剔除的基础知识。

本篇的初始代码在 二、加载场景 一节中修改而得::

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
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.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.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.utils.Array;

public class MainGame extends ApplicationAdapter {
	private static final String data = "model/scene";
	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<ModelInstance> blocks = new Array<ModelInstance>();
	protected Array<ModelInstance> invaders = new Array<ModelInstance>();
	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(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);

		modelBatch.begin(cam);
		visibleCount = 0;
		for ( 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() {
	}
}

这里只是一些变化,让我们看一下它们。首先添加了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的transformation并重置Node的transformation。

如果您不熟悉使用 Stage (scene2d),我建议您也可以学习下一个 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 以跟踪实际呈现的实例数。请注意,空间 ModelInstance 不计算在内并且始终绘制,因为它始终是可见的。

接下来,我们使用 StringBuilder 构建一个字符串,其中包含每秒帧数和实际可见的实例数(不包括空间)。我们设置标签的文本,最后绘制舞台。请注意,强烈建议在您的渲染方法中使用 StringBuilder 以防止字符串连接。StringBuilder 将创建更少的垃圾,几乎不会因垃圾收集而造成故障。

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

我们需要在 resize 方法中更新舞台的视口。最后一个布尔参数将原点设置为左下角坐标,从而在该位置绘制标签。

因此,isVisible 方法是魔术发生的地方,也是决定是否渲染 ModelInstance 的地方。现在它只是一个方法存根并且总是返回 true:

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

让我们运行它并查看它的实际效果:

image.png

如您所见,显示有 37 个可见对象。1 个 ship、6 个 block 和 30 个 invader(以及 1 个未计算在内的空间)。如果您四处移动相机,您会注意到该数字始终保持为 37,无论是否所有对象都实际可见。因此接下来实施视截体剔除了。

如果您不熟悉视截体这个术语:视截体可以被视为类似于 3d 空间中的金字塔的形状,其尖端位于相机处,而主体包含相机可以看到的所有内容。实际上,视截体由 6 个平面组成,即左、右、上、下、近平面和远平面。如果一个物体在这六个平面之间,那么它对相机是可见的,如果它在这六个平面之外(任何一个),那么它对相机是不可见的。

幸运的是,libGDX 提供了一些非常简单的方法来检查对象是否在视截体内。让我们实现该检查:

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

在这里,我们添加了一个 Vector3 来保持位置。在 isVisible 方法中,我们获取 ModelInstance 的位置,接下来我们检查该位置是否在视截体内。让我们看看它的表现如何:

image.png

效果如上,你会发现当你移动/旋转相机时,你会看到物体突然进出。他们被淘汰得太早了。那是因为我们使用 instance.transform.getTranslation(position) 获取的位置代表了 ModelInstance 的中心。虽然实例的中心可能不在视截体内,但这并不意味着完整的实例不在视截体内。例如,如果相机看不到ship的中心,相机仍然可以看到它的部分右翼。

为了解决这个问题,我们需要确保在剔除对象之前,相机真的看不到该对象。然而,对照平截头体检查对象的每个顶点(点)将非常昂贵并且可能适得其反。我们可以使用物体的尺寸来估计相机内部的物体。这将确保对象在视截体内时始终被渲染,但可能导致误报(对象不可见,但其尺寸在视截体内)。

为了实现这一点,我们必须将 ModelInstance 的维度与 ModelInstance 一起存储。这可以通过扩展 ModelInstance 并实现我们需要的(唯一的)构造函数来轻松实现:

public class MainGame extends ApplicationAdapter {
	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 成员中。接下来,我们将 ModelInstance 的维度存储在维度成员中。请注意,使用的 BoundingBox 是一个静态成员,这意味着我们创建的每个 GameObject 都会重用它。

由于我们使用 GameObject 扩展了 ModelInstance,因此我们必须将每次出现的 ModelInstance 替换为 GameObject。

让我们更新 isVisible 方法以包含维度:

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.boundsInFrustum(position, instance.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;
		}
	}

在这里,我们简单地取尺寸的一半长度将其设置为半径。现在更新视截体剔除方法:

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

在这里,我们使用便捷方法 sphereInFrustum 来检查截锥体。请注意,检查半径要快一些,但可能会导致更多误报。