在渲染 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
}
让我们运行它并查看它的实际效果:
如您所见,显示有 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 的位置,接下来我们检查该位置是否在视截体内。让我们看看它的表现如何:
效果如上,你会发现当你移动/旋转相机时,你会看到物体突然进出。他们被淘汰得太早了。那是因为我们使用 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 来检查截锥体。请注意,检查半径要快一些,但可能会导致更多误报。