diff --git a/jme3-examples/src/main/java/jme3test/terrain/TerrainTestCollision.java b/jme3-examples/src/main/java/jme3test/terrain/TerrainTestCollision.java index 6822251ba..faa67fd13 100644 --- a/jme3-examples/src/main/java/jme3test/terrain/TerrainTestCollision.java +++ b/jme3-examples/src/main/java/jme3test/terrain/TerrainTestCollision.java @@ -64,6 +64,9 @@ import com.jme3.terrain.heightmap.ImageBasedHeightMap; import com.jme3.texture.Texture; import com.jme3.texture.Texture.WrapMode; +import java.util.ArrayList; +import java.util.List; + /** * Creates a terrain object and a collision node to go with it. Then * drops several balls from the sky that collide with the terrain @@ -83,7 +86,7 @@ public class TerrainTestCollision extends SimpleApplication { protected BitmapText hintText; PointLight pl; Geometry lightMdl; - Geometry collisionMarker; + List collisionMarkers; private BulletAppState bulletAppState; Geometry collisionSphere; Geometry collisionBox; @@ -103,6 +106,7 @@ public class TerrainTestCollision extends SimpleApplication { @Override public void simpleInitApp() { + collisionMarkers = new ArrayList<>(); bulletAppState = new BulletAppState(); bulletAppState.setThreadingType(BulletAppState.ThreadingType.PARALLEL); stateManager.attach(bulletAppState); @@ -142,6 +146,8 @@ public class TerrainTestCollision extends SimpleApplication { terrain.setLocked(false); // unlock it so we can edit the height rootNode.attachChild(terrain); + // if set to false, only the first collision is returned and collision is slightly faster. + terrain.setSupportMultipleCollisions(true); /** * Create PhysicsRigidBodyControl for collision @@ -227,15 +233,19 @@ public class TerrainTestCollision extends SimpleApplication { super.update(); } - private void createCollisionMarker() { - Sphere s = new Sphere(6, 6, 1); - collisionMarker = new Geometry("collisionMarker"); - collisionMarker.setMesh(s); - Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); - mat.setColor("Color", ColorRGBA.Orange); - collisionMarker.setMaterial(mat); - rootNode.attachChild(collisionMarker); + private void createCollisionMarkers(int num) { + for (int i = 0; i < num; i++) { + Sphere s = new Sphere(6, 6, 1); + Geometry collisionMarker = new Geometry("collisionMarker"); + collisionMarker.setMesh(s); + Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", i == 0 ? ColorRGBA.Orange : ColorRGBA.Blue); + collisionMarker.setMaterial(mat); + rootNode.attachChild(collisionMarker); + collisionMarkers.add(collisionMarker); + } } + private ActionListener actionListener = new ActionListener() { public void onAction(String binding, boolean keyPressed, float tpf) { @@ -247,24 +257,35 @@ public class TerrainTestCollision extends SimpleApplication { terrain.setMaterial(matRock); } } else if (binding.equals("shoot") && !keyPressed) { - Vector3f origin = cam.getWorldCoordinates(new Vector2f(settings.getWidth() / 2, settings.getHeight() / 2), 0.0f); Vector3f direction = cam.getWorldCoordinates(new Vector2f(settings.getWidth() / 2, settings.getHeight() / 2), 0.3f); direction.subtractLocal(origin).normalizeLocal(); - Ray ray = new Ray(origin, direction); CollisionResults results = new CollisionResults(); - int numCollisions = terrain.collideWith(ray, results); - if (numCollisions > 0) { - CollisionResult hit = results.getClosestCollision(); - if (collisionMarker == null) { - createCollisionMarker(); + + if (terrain.collideWith(ray, results) > 0) { + CollisionResult hit = results.getClosestCollision(); // sorts the collection before printing + printCollisions(results); + + // Remove old markers. + for (Geometry g: collisionMarkers) { + g.removeFromParent(); } + collisionMarkers.clear(); + + createCollisionMarkers(results.size()); + + // Position Closest Collision Vector2f loc = new Vector2f(hit.getContactPoint().x, hit.getContactPoint().z); float height = terrain.getHeight(loc); - System.out.println("collide " + hit.getContactPoint() + ", height: " + height + ", distance: " + hit.getDistance()); - collisionMarker.setLocalTranslation(new Vector3f(hit.getContactPoint().x, height, hit.getContactPoint().z)); + System.out.println("Closest Collision: " + hit.getContactPoint() + ", height: " + height + ", distance: " + hit.getDistance()); + collisionMarkers.get(0).setLocalTranslation(new Vector3f(hit.getContactPoint().x, height, hit.getContactPoint().z)); + + // Position Rest: When getClosestCollision has been called, the results are sorted, and thus 0 is closest. + for (int i = 1; i < results.size(); i++) { + collisionMarkers.get(i).setLocalTranslation(results.getCollision(i).getContactPoint()); + } } } else if (binding.equals("cameraDown") && !keyPressed) { getCamera().lookAtDirection(new Vector3f(0, -1, 0), Vector3f.UNIT_Y); @@ -302,4 +323,14 @@ public class TerrainTestCollision extends SimpleApplication { selectedCollisionObject.setLocalTranslation(oldLoc); } } + + private void printCollisions(CollisionResults cr) { + System.out.println("================ Collision Results ================"); + for (int i = 0; i < cr.size(); i++) { + CollisionResult res = cr.getCollision(i); + System.out.println("Result " + i); + System.out.println("\t\t" + res.toString()); + } + System.out.println("================ END Collision Results ================"); + } } diff --git a/jme3-terrain/build.gradle b/jme3-terrain/build.gradle index 0a77d7d72..a782045c6 100644 --- a/jme3-terrain/build.gradle +++ b/jme3-terrain/build.gradle @@ -4,4 +4,8 @@ if (!hasProperty('mainClass')) { dependencies { compile project(':jme3-core') + testCompile project(':jme3-core') + testCompile project(':jme3-desktop') + testCompile project(':jme3-core').sourceSets.test.output + testCompile project(':jme3-testdata') } diff --git a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java index 7f41bd372..f89198f25 100644 --- a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java +++ b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java @@ -123,7 +123,7 @@ public class TerrainQuad extends Node implements Terrain { private int maxLod = -1; private BoundingBox affectedAreaBBox; // only set in the root quad - private TerrainPicker picker; + private TerrainPicker picker = new BresenhamTerrainPicker(this); private Vector3f lastScale = Vector3f.UNIT_XYZ; protected NeighbourFinder neighbourFinder; @@ -274,20 +274,7 @@ public class TerrainQuad extends Node implements Terrain { } private int collideWithRay(Ray ray, CollisionResults results) { - if (picker == null) - picker = new BresenhamTerrainPicker(this); - - Vector3f intersection = picker.getTerrainIntersection(ray, results); - if (intersection != null) { - if (ray.getLimit() < Float.POSITIVE_INFINITY) { - if (results.getClosestCollision().getDistance() <= ray.getLimit()) - return 1; // in range - else - return 0; // out of range - } else - return 1; - } else - return 0; + return picker.getTerrainIntersection(ray, results); } /** @@ -1915,5 +1902,23 @@ public class TerrainQuad extends Node implements Terrain { return hm; } + + /** + * When colliding with this terrain, is a report of all collisions wanted or only the closest collision?
+ * If only the closest collision is required, the collision calculation will be faster.
+ * Note: If no collision happens, it takes as long as a collision with multipleCollisions on would take. + * + * @param set Whether to support multiple collisions or not + */ + public void setSupportMultipleCollisions(boolean set) { + if (picker == null) { + // This is so that it doesn't fail at the IllegalStateException because null !instanceof Anything + throw new NullPointerException("TerrainPicker is null"); + } else if (picker instanceof BresenhamTerrainPicker) { + ((BresenhamTerrainPicker)picker).setSupportMultipleCollisions(set); + } else { + throw new IllegalStateException("The underlying picking implementation does not support multiple collisions"); + } + } } diff --git a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/BresenhamTerrainPicker.java b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/BresenhamTerrainPicker.java index cf7efc085..5adf4f2bd 100644 --- a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/BresenhamTerrainPicker.java +++ b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/BresenhamTerrainPicker.java @@ -67,31 +67,41 @@ public class BresenhamTerrainPicker implements TerrainPicker { private final TerrainQuad root; private final BresenhamYUpGridTracer tracer = new BresenhamYUpGridTracer(); + private boolean multipleCollisions = true; + public BresenhamTerrainPicker(TerrainQuad root) { this.root = root; } - public Vector3f getTerrainIntersection(Ray worldPick, CollisionResults results) { + public void setSupportMultipleCollisions(boolean multipleCollisions) { + this.multipleCollisions = multipleCollisions; + } + public boolean isSupportingMultipleCollisions() { + return multipleCollisions; + } + + public int getTerrainIntersection(Ray worldPick, CollisionResults results) { + int numCollisions = 0; worldPickRay.set(worldPick); - List pickData = new ArrayList(); + List pickData = new ArrayList<>(); root.findPick(worldPick.clone(), pickData); Collections.sort(pickData); - if (pickData.isEmpty()) - return null; + if (pickData.isEmpty()) { + return 0; + } workRay.set(worldPick); for (TerrainPickData pd : pickData) { TerrainPatch patch = pd.targetPatch; - tracer.getGridSpacing().set(patch.getWorldScale()); tracer.setGridOrigin(patch.getWorldTranslation()); - workRay.getOrigin().set(worldPick.getDirection()).multLocal(pd.cr.getDistance()-.1f).addLocal(worldPick.getOrigin()); + workRay.getOrigin().set(worldPick.getDirection()).multLocal(pd.cr.getDistance() - .1f).addLocal(worldPick.getOrigin()); tracer.startWalk(workRay); @@ -99,67 +109,141 @@ public class BresenhamTerrainPicker implements TerrainPicker { final Vector2f loc = tracer.getGridLocation(); if (tracer.isRayPerpendicularToGrid()) { - Triangle hit = new Triangle(); - checkTriangles(loc.x, loc.y, workRay, intersection, patch, hit); - float distance = worldPickRay.origin.distance(intersection); - CollisionResult cr = new CollisionResult(intersection, distance); - cr.setGeometry(patch); - cr.setContactNormal(hit.getNormal()); - results.addCollision(cr); - return intersection; - } - - - - while (loc.x >= -1 && loc.x <= patch.getSize() && - loc.y >= -1 && loc.y <= patch.getSize()) { - - //System.out.print(loc.x+","+loc.y+" : "); - // check the triangles of main square for intersection. Triangle hit = new Triangle(); if (checkTriangles(loc.x, loc.y, workRay, intersection, patch, hit)) { - // we found an intersection, so return that! float distance = worldPickRay.origin.distance(intersection); - CollisionResult cr = new CollisionResult(intersection, distance); - cr.setGeometry(patch); - results.addCollision(cr); - cr.setContactNormal(hit.getNormal()); - return intersection; - } - // because of how we get our height coords, we will - // sometimes be off by a grid spot, so we check the next - // grid space up. - int dx = 0, dz = 0; - Direction d = tracer.getLastStepDirection(); - switch (d) { - case PositiveX: - case NegativeX: - dx = 0; - dz = 1; - break; - case PositiveZ: - case NegativeZ: - dx = 1; - dz = 0; - break; + //@TODO: Verify if it's even possible to have a ray hit multiple "PickData"s when being perpendicular at all. + // because otherwise, we could always return 1 here. + if (worldPick.getLimit() < Float.POSITIVE_INFINITY) { + if (distance <= worldPick.getLimit()) { + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + } // else return 0; // < this is the old behavior, since the code checked for the range afterwards. + } else { // unlimited range + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + } + } // else no collision + } else { // If the ray is perpendicular, tracer.next() would never advance loc, leading to an infinite loop. + while (loc.x >= -1 && loc.x <= patch.getSize() && + loc.y >= -1 && loc.y <= patch.getSize()) { + + //System.out.print(loc.x + "," + loc.y + " : "); + // check the triangles of main square for intersection. + Triangle hit = new Triangle(); + if (checkTriangles(loc.x, loc.y, workRay, intersection, patch, hit)) { + // we found an intersection, so return that! + float distance = worldPickRay.origin.distance(intersection); + + if (worldPick.getLimit() < Float.POSITIVE_INFINITY) { + if (distance <= worldPick.getLimit()) { + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + }// else return 0; // < this is the old behavior, since the code checked for the range afterwards. + } else { // unlimited range + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + } + } + + // because of how we get our height coords, we will + // sometimes be off by a grid spot, so we check the next + // grid space up. + int dx = 0, dz = 0; + Direction d = tracer.getLastStepDirection(); + switch (d) { + case PositiveX: + case NegativeX: + dx = 0; + dz = 1; + break; + case PositiveZ: + case NegativeZ: + dx = 1; + dz = 0; + break; + } + + if (checkTriangles(loc.x + dx, loc.y + dz, workRay, intersection, patch, hit)) { + // we found an intersection, so return that! + float distance = worldPickRay.origin.distance(intersection); + + if (worldPick.getLimit() < Float.POSITIVE_INFINITY) { + if (distance <= worldPick.getLimit()) { + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + } // else return null; // < this is the old behavior, since the code checked for the range afterwards. + } else { // unlimited range + if (addCollision(results, patch, intersection, hit, distance)) { + if (!multipleCollisions) { + return 1; + } else { + numCollisions++; + } + } + } + } + + tracer.next(); } + } + } - if (checkTriangles(loc.x + dx, loc.y + dz, workRay, intersection, patch, hit)) { - // we found an intersection, so return that! - float distance = worldPickRay.origin.distance(intersection); - CollisionResult cr = new CollisionResult(intersection, distance); - results.addCollision(cr); - cr.setGeometry(patch); - cr.setContactNormal(hit.getNormal()); - return intersection; - } + return numCollisions; + } - tracer.next(); + /** + * This method adds the found Collision to an existing collisionResult. + * @param results The results to add this collision to + * @param patch The TerrainPatch which collided + * @param intersection The actual intersection position + * @param hit The hit triangle + * @param distance The distance at which the hit occurred + * @return Whether the collision was accepted to the list or whether it has been deduplicated + */ + private boolean addCollision(CollisionResults results, TerrainPatch patch, Vector3f intersection, Triangle hit, float distance) { + CollisionResult cr = new CollisionResult(intersection.clone(), distance); + cr.setGeometry(patch); + cr.setContactNormal(hit.getNormal()); + cr.setTriangleIndex(hit.getIndex()); // this will probably always be 0 + + for (int i = 0; i < results.size(); i++) { + CollisionResult compare = results.getCollision(i); + if (compare.getDistance() == cr.getDistance() && compare.getGeometry() == cr.getGeometry() && + compare.getContactPoint().equals(cr.getContactPoint()) && + compare.getContactNormal().equals(cr.getContactNormal())) { + return false; // Collision already available, deduplicate. } } - return null; + results.addCollision(cr); + return true; } protected boolean checkTriangles(float gridX, float gridY, Ray pick, Vector3f intersection, TerrainPatch patch, Triangle store) { diff --git a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/TerrainPicker.java b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/TerrainPicker.java index 08308849f..bd45f3a08 100644 --- a/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/TerrainPicker.java +++ b/jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/picking/TerrainPicker.java @@ -47,9 +47,8 @@ public interface TerrainPicker { * * @param worldPick * our pick ray, in world space. - * @return null if no pick is found. Otherwise it returns a Vector3f populated with the pick - * coordinates. + * @return The number of collisions found */ - public Vector3f getTerrainIntersection(final Ray worldPick, CollisionResults results); + public int getTerrainIntersection(final Ray worldPick, CollisionResults results); } diff --git a/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseAWTTest.java b/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseAWTTest.java new file mode 100644 index 000000000..4ff66b4bf --- /dev/null +++ b/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseAWTTest.java @@ -0,0 +1,38 @@ +package com.jme3.terrain.collision; + +import com.jme3.asset.AssetManager; +import com.jme3.system.JmeDesktopSystem; +import com.jme3.system.JmeSystem; + +/** + * This class provides some utility functions to properly test the jMonkeyEngine.
+ * Thus it contains simple methods to get and create a headless assetManager amongst other things.
+ * In comparison to {@link BaseTest} it provides a DesktopAssetManager capable of loading image formats using AWT, which + * however makes those tests unsuitable for headless ci testing. This requires jme3-desktop to be a testRuntime dependency. + * + * @author MeFisto94 + */ +public abstract class BaseAWTTest { + private AssetManager assetManager; + + static { + //JmeSystem.setSystemDelegate(new JmeDesktopSystem()); + } + + public AssetManager getAssetManager() { + if (assetManager == null) { + assetManager = createAssetManager(); + } + + return assetManager; + } + + private AssetManager createAssetManager() { + /* Desktop.cfg supports the following additional file formats at the time of writing: + LOADER com.jme3.texture.plugins.AWTLoader : jpg, bmp, gif, png, jpeg + LOADER com.jme3.audio.plugins.OGGLoader : ogg + */ + return JmeSystem.newAssetManager(BaseTest.class.getResource("/com/jme3/asset/Desktop.cfg")); + } + +} diff --git a/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseTest.java b/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseTest.java new file mode 100644 index 000000000..15f751855 --- /dev/null +++ b/jme3-terrain/src/test/java/com/jme3/terrain/collision/BaseTest.java @@ -0,0 +1,28 @@ +package com.jme3.terrain.collision; + +import com.jme3.asset.AssetManager; +import com.jme3.system.TestUtil; + +/** + * This class provides some utility functions to properly test the jMonkeyEngine.
+ * Thus it contains simple methods to get and create a headless assetManager amongst other things.
+ * If you need support for image/texture formats (png, tga, jpg, ...) see {@link BaseAWTTest} + * + * @author MeFisto94 + */ +public abstract class BaseTest { + private AssetManager assetManager; + + public AssetManager getAssetManager() { + if (assetManager == null) { + assetManager = createAssetManager(); + } + + return assetManager; + } + + private AssetManager createAssetManager() { + return TestUtil.createAssetManager(); + } + +} diff --git a/jme3-terrain/src/test/java/com/jme3/terrain/collision/TerrainCollisionTest.java b/jme3-terrain/src/test/java/com/jme3/terrain/collision/TerrainCollisionTest.java new file mode 100644 index 000000000..32768f406 --- /dev/null +++ b/jme3-terrain/src/test/java/com/jme3/terrain/collision/TerrainCollisionTest.java @@ -0,0 +1,87 @@ +package com.jme3.terrain.collision; + +import com.jme3.collision.CollisionResults; +import com.jme3.math.Ray; +import com.jme3.math.Vector3f; +import com.jme3.terrain.geomipmap.TerrainQuad; +import com.jme3.terrain.heightmap.AbstractHeightMap; +import com.jme3.terrain.heightmap.ImageBasedHeightMap; +import com.jme3.texture.Texture; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class TerrainCollisionTest extends BaseAWTTest { + TerrainQuad quad; + + @Before + public void initQuad() { + Texture heightMapImage = getAssetManager().loadTexture("Textures/Terrain/splat/mountains512.png"); + AbstractHeightMap map = new ImageBasedHeightMap(heightMapImage.getImage(), 0.25f); + map.load(); + quad = new TerrainQuad("terrain", 65, 513, map.getHeightMap()); + } + + /** + * Due to a previous bug, when no collision should happen, the CollisionResults struct was still populated, leading + * to an incoherency of data and ghost collisions when passing a non-empty CR. + */ + @Test + public void testNoCollision() { + Ray r = new Ray(new Vector3f(0f, 40f, 0f), Vector3f.UNIT_Y.negate()); + r.setLimit(0.1f); + CollisionResults cr = new CollisionResults(); + long l = System.nanoTime(); + int cw = quad.collideWith(r, cr); + System.out.println((System.nanoTime() - l) + " ns"); + + Assert.assertEquals(0, cw); + Assert.assertEquals(0, cr.size()); + Assert.assertEquals(null, cr.getClosestCollision()); + Assert.assertEquals(null, cr.getFarthestCollision()); + } + + @Test + public void testPerpendicularCollision() { + Ray r = new Ray(new Vector3f(0f, 40f, 0f), Vector3f.UNIT_Y.negate()); + CollisionResults cr = new CollisionResults(); + int cw = quad.collideWith(r, cr); + + Assert.assertEquals(1, cw); + Assert.assertEquals(1, cr.size()); + Assert.assertEquals(new Vector3f(0f, 28f, 0f), cr.getClosestCollision().getContactPoint()); + Assert.assertEquals(new Vector3f(-0.5144958f, 0.6859944f, 0.5144958f), cr.getClosestCollision().getContactNormal()); + Assert.assertEquals(12, cr.getClosestCollision().getDistance(), 0.01d); + Assert.assertEquals(0, cr.getClosestCollision().getTriangleIndex()); + } + + @Test + public void testMultiCollision() { + // Ray parameters obtained by using TerrainTestCollision (manual inspection of a feasible ray and commenting out setLocalScale(2) + Ray r = new Ray(new Vector3f(-38.689114f, 35.622643f, -40.222355f), new Vector3f(0.68958646f, 0.0980845f, 0.7175304f)); + + CollisionResults cr = new CollisionResults(); + long l = System.nanoTime(); + int cw = quad.collideWith(r, cr); + System.out.println((System.nanoTime() - l) + " ns"); + Assert.assertEquals(6, cw); + Assert.assertEquals(6, cr.size()); + + } + + @Test + public void testPreventRegression() { + // This test is as the multi collision changes lead to a regression where sometimes a collision was ignored + // Ray parameters obtained by using TerrainTestCollision (manual inspection of a feasible ray and commenting out setLocalScale(2)) + Ray r = new Ray(new Vector3f(101.61858f, 78.35965f, 17.645157f), new Vector3f(-0.4188528f, -0.56462675f, 0.71116734f)); + + CollisionResults cr = new CollisionResults(); + quad.collideWith(r, cr); + + Assert.assertEquals(3, cr.size()); + Assert.assertEquals(68.1499f, cr.getClosestCollision().getDistance(), 0.01f); + Assert.assertEquals(new Vector3f(73.07381f, 39.88039f, 66.11114f), cr.getClosestCollision().getContactPoint()); + Assert.assertEquals(new Vector3f(0.9103665f, 0.33104235f, -0.24828176f), cr.getClosestCollision().getContactNormal()); + } + +}