Browse Source

Replaces fullscreen quad with a viewport covering triangle (#2589)

* *Add FullscreenTriangle.java

* *Update Postprocessor to use the new FullscreenTriangle geometry

* Changed post vertex shaders to work with the already correct position coordinates

* Revert to the old vertex shader code

* Reworked the positions so that the vertex positions are correct with the post.vert transformations

* Added support for using fullscreen triangle or quad in `FilterPostProcessor` rendering.

* Introduce support for multi-scenario screenshot tests to validate identical results across approaches.

* Updated `TestFog` to support multi-scenario screenshot testing with `FullscreenQuad` and `FullscreenTriangle` setups.

---------

Co-authored-by: Richard Tingle <[email protected]>
Michael Zuegg 1 week ago
parent
commit
4e1906f8d4

+ 26 - 4
jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java

@@ -46,6 +46,8 @@ import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.Renderer;
 import com.jme3.renderer.ViewPort;
 import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.shape.FullscreenTriangle;
 import com.jme3.texture.FrameBuffer;
 import com.jme3.texture.FrameBuffer.FrameBufferTarget;
 import com.jme3.texture.Image.Format;
@@ -88,7 +90,8 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
     private Texture2D depthTexture;
     private SafeArrayList<Filter> filters = new SafeArrayList<>(Filter.class);
     private AssetManager assetManager;
-    private Picture fsQuad;
+    private boolean useFullscreenTriangle = false;
+    private Geometry fsQuad;
     private boolean computeDepth = false;
     private FrameBuffer outputBuffer;
     private int width;
@@ -116,6 +119,18 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
         this.assetManager = assetManager;
     }
 
+    /**
+     * Constructs a new instance of FilterPostProcessor.
+     *
+     * @param assetManager The asset manager used to load resources needed by the processor.
+     * @param useFullscreenTriangle If true, a fullscreen triangle will be used for rendering;
+     *                              otherwise, a quad will be used.
+     */
+    public FilterPostProcessor(AssetManager assetManager, boolean useFullscreenTriangle) {
+        this(assetManager);
+        this.useFullscreenTriangle = useFullscreenTriangle;
+    }
+
     /**
      * Serialization-only constructor. Do not use this constructor directly;
      * use {@link #FilterPostProcessor(AssetManager)}.
@@ -181,9 +196,14 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
         renderManager = rm;
         renderer = rm.getRenderer();
         viewPort = vp;
-        fsQuad = new Picture("filter full screen quad");
-        fsQuad.setWidth(1);
-        fsQuad.setHeight(1);
+        if(useFullscreenTriangle) {
+            fsQuad = new Geometry("FsQuad", new FullscreenTriangle());
+        }else{
+            Picture fullscreenQuad = new Picture("filter full screen quad");
+            fullscreenQuad.setWidth(1);
+            fullscreenQuad.setHeight(1);
+            fsQuad = fullscreenQuad;
+        }
 
         // Determine optimal framebuffer format based on renderer capabilities
         if (!renderer.getCaps().contains(Caps.PackedFloatTexture)) {
@@ -715,6 +735,7 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
     public void write(JmeExporter ex) throws IOException {
         OutputCapsule oc = ex.getCapsule(this);
         oc.write(numSamples, "numSamples", 0);
+        oc.write(useFullscreenTriangle, "useFullscreenTriangle", false);
         oc.writeSavableArrayList(new ArrayList(filters), "filters", null);
     }
 
@@ -723,6 +744,7 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
     public void read(JmeImporter im) throws IOException {
         InputCapsule ic = im.getCapsule(this);
         numSamples = ic.readInt("numSamples", 0);
+        useFullscreenTriangle = ic.readBoolean("useFullscreenTriangle", false);
         filters = new SafeArrayList<>(Filter.class, ic.readSavableArrayList("filters", null));
         for (Filter filter : filters.getArray()) {
             filter.setProcessor(this);

+ 39 - 0
jme3-core/src/main/java/com/jme3/scene/shape/FullscreenTriangle.java

@@ -0,0 +1,39 @@
+package com.jme3.scene.shape;
+
+import com.jme3.scene.Mesh;
+import com.jme3.scene.VertexBuffer;
+
+
+/**
+ * The FullscreenTriangle class defines a mesh representing a single
+ * triangle that spans the entire screen. It is typically used in rendering
+ * techniques where a fullscreen quad or triangle is needed, such as in
+ * post-processing effects or screen-space operations.
+ */
+public class FullscreenTriangle extends Mesh {
+
+    /**
+     * Encapsulates the vertex positions for a fullscreen triangle.
+     * The positions are transformed by the vertex shader to cover the entire screen.
+     */
+    private static final float[] POSITIONS = {
+        0, 0, 0,  // -1 -1 0  after vertex shader transform
+        2, 0, 0,  //  3 -1 0  after vertex shader transform
+        0, 2, 0   // -1  3 0  after vertex shader transform
+    };
+
+    private static final float[] TEXCOORDS = {
+            0,0,
+            2,0,
+            0,2
+    };
+
+
+    public FullscreenTriangle() {
+        super();
+        setBuffer(VertexBuffer.Type.Position, 3, POSITIONS);
+        setBuffer(VertexBuffer.Type.TexCoord, 2, TEXCOORDS);
+        setBuffer(VertexBuffer.Type.Index, 3, new short[]{0, 1, 2});
+        updateBound();
+    }
+}

+ 2 - 2
jme3-effects/src/main/resources/Common/MatDefs/Post/Post.vert

@@ -4,8 +4,8 @@ attribute vec2 inTexCoord;
 
 varying vec2 texCoord;
 
-void main() {     
+void main() {
     vec2 pos = inPosition.xy * 2.0 - 1.0;
-    gl_Position = vec4(pos, 0.0, 1.0);    
+    gl_Position = vec4(pos, 0.0, 1.0);
     texCoord = inTexCoord;
 }

+ 1 - 1
jme3-effects/src/main/resources/Common/MatDefs/Post/Post15.vert

@@ -6,7 +6,7 @@ in vec2 inTexCoord;
 out vec2 texCoord;
 
 void main() {
-    vec2 pos = inPosition.xy * 2.0 - 1.0;      
+    vec2 pos = inPosition.xy * 2.0 - 1.0;
     gl_Position = vec4(pos, 0.0, 1.0);
     texCoord = inTexCoord;
 }

+ 13 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/Scenario.java

@@ -0,0 +1,13 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.state.AppState;
+
+public class Scenario {
+    String scenarioName;
+    AppState[] states;
+
+    public Scenario(String scenarioName, AppState... states) {
+        this.scenarioName = scenarioName;
+        this.states = states;
+    }
+}

+ 11 - 3
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java

@@ -47,7 +47,11 @@ public class ScreenshotTest{
 
     TestType testType = TestType.MUST_PASS;
 
-    AppState[] states;
+    /**
+     * Usually there will be a single scenario but sometimes it will be desirable to test that two ways
+     * of doing something produce the same result. In that case there will be multiple scenarios.
+     */
+    List<Scenario> scenarios = new ArrayList<>();
 
     List<Integer> framesToTakeScreenshotsOn = new ArrayList<>();
 
@@ -56,7 +60,11 @@ public class ScreenshotTest{
     String baseImageFileName = null;
 
     public ScreenshotTest(AppState... initialStates){
-        states = initialStates;
+        scenarios.add(new Scenario("SimpleSingleScenario", initialStates));
+        framesToTakeScreenshotsOn.add(1); //default behaviour is to take a screenshot on the first frame
+    }
+    public ScreenshotTest(Scenario... scenarios){
+        this.scenarios.addAll(Arrays.asList(scenarios));
         framesToTakeScreenshotsOn.add(1); //default behaviour is to take a screenshot on the first frame
     }
 
@@ -100,7 +108,7 @@ public class ScreenshotTest{
 
         String imageFilePrefix = baseImageFileName == null ? calculateImageFilePrefix() : baseImageFileName;
 
-        TestDriver.bootAppForTest(testType,settings,imageFilePrefix, framesToTakeScreenshotsOn, states);
+        TestDriver.bootAppForTest(testType,settings,imageFilePrefix, framesToTakeScreenshotsOn, scenarios);
     }
 
 

+ 13 - 1
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java

@@ -47,10 +47,22 @@ public abstract class ScreenshotTestBase{
     /**
      * Initialises a screenshot test. The resulting object should be configured (if neccessary) and then started
      * by calling {@link ScreenshotTest#run()}.
-     * @param initialStates
+     * @param initialStates the states that will create the JME environment
      * @return
      */
     public ScreenshotTest screenshotTest(AppState... initialStates){
         return new ScreenshotTest(initialStates);
     }
+
+    /**
+     * Permits multiple scenarios to be tested in a single test. Each scenario should give identical results and
+     * will have a screenshot taken on the same frame.
+     *
+     * <p>
+     *     This is intended for testing migrations where the old and new approach should both give identical results.
+     * </p>
+     */
+    public ScreenshotTest screenshotMultiScenarioTest(Scenario... scenarios){
+        return new ScreenshotTest(scenarios);
+    }
 }

+ 107 - 59
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java

@@ -56,7 +56,9 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
@@ -78,7 +80,9 @@ public class TestDriver extends BaseAppState{
 
     private static final Logger logger = Logger.getLogger(TestDriver.class.getName());
 
-    public static final String IMAGES_ARE_DIFFERENT = "Images are different. (If you are running the test locally this is expected, images only reproducible on github CI infrastructure)";
+    public static final String IMAGES_ARE_DIFFERENT = "Generated images is different from committed image. (If you are running the test locally this is expected, images only reproducible on github CI infrastructure)";
+
+    public static final String IMAGES_ARE_DIFFERENT_BETWEEN_SCENARIOS = "Images are different between scenarios.";
 
     public static final String IMAGES_ARE_DIFFERENT_SIZES = "Images are different sizes.";
 
@@ -145,85 +149,127 @@ public class TestDriver extends BaseAppState{
      * - After all the frames have been taken it stops the application
      * - Compares the screenshot to the expected screenshot (if any). Fails the test if they are different
      */
-    public static void bootAppForTest(TestType testType, AppSettings appSettings, String baseImageFileName, List<Integer> framesToTakeScreenshotsOn, AppState... initialStates){
-        FastMath.rand.setSeed(0); //try to make things deterministic by setting the random seed
+    public static void bootAppForTest(TestType testType, AppSettings appSettings, String baseImageFileName, List<Integer> framesToTakeScreenshotsOn, List<Scenario> scenarios){
+
         Collections.sort(framesToTakeScreenshotsOn);
 
-        Path imageTempDir;
+        List<Path> tempFolders = new ArrayList<>();
+        Map<Scenario, List<Path>> imageFilesPerScenario = new HashMap<>();
+
+        // usually there is a single scenario, but the framework can be set up to expect multiple scenarios that give identical results
+        for(Scenario scenario : scenarios) {
+            FastMath.rand.setSeed(0); //try to make things deterministic by setting the random seed
+            Path imageTempDir;
+            try {
+                imageTempDir = Files.createTempDirectory("jmeSnapshotTest");
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+            tempFolders.add(imageTempDir);
 
-        try{
-            imageTempDir = Files.createTempDirectory("jmeSnapshotTest");
-        } catch(IOException e){
-            throw new RuntimeException(e);
-        }
+            ScreenshotNoInputAppState screenshotAppState = new ScreenshotNoInputAppState(imageTempDir.toString() + "/");
+            String screenshotAppFileNamePrefix = "Screenshot-";
+            screenshotAppState.setFileName(screenshotAppFileNamePrefix);
 
-        ScreenshotNoInputAppState screenshotAppState = new ScreenshotNoInputAppState(imageTempDir.toString() + "/");
-        String screenshotAppFileNamePrefix = "Screenshot-";
-        screenshotAppState.setFileName(screenshotAppFileNamePrefix);
+            List<AppState> states = new ArrayList<>(Arrays.asList(scenario.states));
+            TestDriver testDriver = new TestDriver(screenshotAppState, framesToTakeScreenshotsOn);
+            states.add(screenshotAppState);
+            states.add(testDriver);
 
-        List<AppState> states = new ArrayList<>(Arrays.asList(initialStates));
-        TestDriver testDriver = new TestDriver(screenshotAppState, framesToTakeScreenshotsOn);
-        states.add(screenshotAppState);
-        states.add(testDriver);
+            SimpleApplication app = new App(states.toArray(new AppState[0]));
+            app.setSettings(appSettings);
+            app.setShowSettings(false);
 
-        SimpleApplication app = new App(states.toArray(new AppState[0]));
-        app.setSettings(appSettings);
-        app.setShowSettings(false);
+            testDriver.waitLatch = new CountDownLatch(1);
+            executor.execute(() -> app.start(JmeContext.Type.Display));
 
-        testDriver.waitLatch = new CountDownLatch(1);
-        executor.execute(() -> app.start(JmeContext.Type.Display));
+            int maxWaitTimeMilliseconds = 45000;
 
-        int maxWaitTimeMilliseconds = 45000;
+            try {
+                boolean exitedProperly = testDriver.waitLatch.await(maxWaitTimeMilliseconds, TimeUnit.MILLISECONDS);
 
-        try {
-            boolean exitedProperly = testDriver.waitLatch.await(maxWaitTimeMilliseconds, TimeUnit.MILLISECONDS);
+                if (!exitedProperly) {
+                    logger.warning("Test driver did not exit in " + maxWaitTimeMilliseconds + "ms. Timed out");
+                    app.stop(true);
+                }
 
-            if(!exitedProperly){
-                logger.warning("Test driver did not exit in " + maxWaitTimeMilliseconds + "ms. Timed out");
-                app.stop(true);
+                Thread.sleep(1000); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
             }
 
-            Thread.sleep(1000); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            throw new RuntimeException(e);
-        }
+            //search the imageTempDir
+            List<Path> imageFiles = new ArrayList<>();
+            try (Stream<Path> paths = Files.list(imageTempDir)) {
+                paths.forEach(imageFiles::add);
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
 
-        //search the imageTempDir
-        List<Path> imageFiles = new ArrayList<>();
-        try(Stream<Path> paths = Files.list(imageTempDir)){
-            paths.forEach(imageFiles::add);
-        } catch(IOException e){
-            throw new RuntimeException(e);
-        }
+            //this resorts with natural numeric ordering (so App10.png comes after App9.png)
+            imageFiles.sort(new Comparator<Path>() {
+                @Override
+                public int compare(Path p1, Path p2) {
+                    return extractNumber(p1).compareTo(extractNumber(p2));
+                }
 
-        //this resorts with natural numeric ordering (so App10.png comes after App9.png)
-        imageFiles.sort(new Comparator<Path>(){
-            @Override
-            public int compare(Path p1, Path p2){
-                return extractNumber(p1).compareTo(extractNumber(p2));
+                private Integer extractNumber(Path path) {
+                    String name = path.getFileName().toString();
+                    int numStart = screenshotAppFileNamePrefix.length();
+                    int numEnd = name.lastIndexOf(".png");
+                    return Integer.parseInt(name.substring(numStart, numEnd));
+                }
+            });
+            if (imageFiles.isEmpty()) {
+                fail("No screenshot found in the temporary directory. Did the application crash?");
             }
-
-            private Integer extractNumber(Path path){
-                String name = path.getFileName().toString();
-                int numStart = screenshotAppFileNamePrefix.length();
-                int numEnd = name.lastIndexOf(".png");
-                return Integer.parseInt(name.substring(numStart, numEnd));
+            if (imageFiles.size() != framesToTakeScreenshotsOn.size()) {
+                fail("Not all screenshots were taken, expected " + framesToTakeScreenshotsOn.size() + " but got " + imageFiles.size());
             }
-        });
 
-        if(imageFiles.isEmpty()){
-            fail("No screenshot found in the temporary directory. Did the application crash?");
+            imageFilesPerScenario.put(scenario, imageFiles);
         }
-        if(imageFiles.size() != framesToTakeScreenshotsOn.size()){
-            fail("Not all screenshots were taken, expected " + framesToTakeScreenshotsOn.size() + " but got " + imageFiles.size());
-        }
-
         String failureMessage = null;
 
         try {
+            List<Path> primeScenarioScreenshots = imageFilesPerScenario.get(scenarios.get(0));
+
+            if(imageFilesPerScenario.size()>1){
+                String primeScenarioName = scenarios.get(0).scenarioName;
+
+                // check each scenario gave the same results (before checking a single scenario against the reference images
+                for(int i=1;i<imageFilesPerScenario.size();i++){
+                    String thisScenarioName = scenarios.get(i).scenarioName;
+                    List<Path> otherScenarioScreenshots = imageFilesPerScenario.get(scenarios.get(i));
+                    for(int screenshotIndex=0;screenshotIndex<framesToTakeScreenshotsOn.size();screenshotIndex++) {
+                        Path primeImage = primeScenarioScreenshots.get(screenshotIndex);
+                        Path otherImage = otherScenarioScreenshots.get(screenshotIndex);
+
+                        BufferedImage img1 = ImageIO.read(primeImage.toFile());
+                        BufferedImage img2 = ImageIO.read(otherImage.toFile());
+
+                        int frame = framesToTakeScreenshotsOn.get(screenshotIndex);
+
+                        String thisFrameBaseImageFileName = baseImageFileName + "_f" + frame;
+
+                        if (!imagesAreTheSame(img1, img2)) {
+                            attachImage("Scenario " + primeScenarioName + " " + screenshotIndex, thisFrameBaseImageFileName + "_" + primeScenarioName + ".png", img1);
+                            attachImage("Scenario " + thisScenarioName + " " + screenshotIndex, thisFrameBaseImageFileName + "_" + thisScenarioName + ".png", img2);
+                            attachImage("Diff (between above scenarios)", thisFrameBaseImageFileName + "_" + primeScenarioName + "_" + thisScenarioName + "_diff.png", createComparisonImage(img1, img2));
+
+                            if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
+                                failureMessage = IMAGES_ARE_DIFFERENT_BETWEEN_SCENARIOS;
+                            }
+                            ExtentReportExtension.getCurrentTest().fail(IMAGES_ARE_DIFFERENT_BETWEEN_SCENARIOS);
+                        }
+                    }
+                }
+            }
+
+
             for(int screenshotIndex=0;screenshotIndex<framesToTakeScreenshotsOn.size();screenshotIndex++){
-                Path generatedImage = imageFiles.get(screenshotIndex);
+                Path generatedImage = primeScenarioScreenshots.get(screenshotIndex);
                 int frame = framesToTakeScreenshotsOn.get(screenshotIndex);
 
                 String thisFrameBaseImageFileName = baseImageFileName + "_f" + frame;
@@ -280,7 +326,9 @@ public class TestDriver extends BaseAppState{
         } catch (IOException e) {
             throw new RuntimeException("Error reading images", e);
         } finally{
-            clearTemporaryFolder(imageTempDir);
+            for(Path imageTempDir : tempFolders){
+                clearTemporaryFolder(imageTempDir);
+            }
         }
 
         if(failureMessage!=null){

+ 101 - 2
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestFog.java

@@ -49,6 +49,7 @@ import com.jme3.terrain.heightmap.AbstractHeightMap;
 import com.jme3.terrain.heightmap.ImageBasedHeightMap;
 import com.jme3.texture.Texture;
 import com.jme3.util.SkyFactory;
+import org.jmonkeyengine.screenshottests.testframework.Scenario;
 import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
 import org.junit.jupiter.api.Test;
 
@@ -67,7 +68,7 @@ public class TestFog extends ScreenshotTestBase {
      */
     @Test
     public void testFog() {
-        screenshotTest(new BaseAppState() {
+        screenshotMultiScenarioTest(new Scenario("FullscreenQuad",new BaseAppState() {
             @Override
             protected void initialize(Application app) {
                 SimpleApplication simpleApplication = (SimpleApplication) app;
@@ -165,7 +166,105 @@ public class TestFog extends ScreenshotTestBase {
                 System.out.println(getApplication().getCamera().getLocation());
             }
 
-        })
+        }),new Scenario("FullscreenTriangle",new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+
+                simpleApplication.getCamera().setLocation(new Vector3f(-34.74095f, 95.21318f, -287.4945f));
+                simpleApplication.getCamera().setRotation(new Quaternion(0.023536969f, 0.9361278f, -0.016098259f, -0.35050195f));
+
+                Node mainScene = new Node();
+
+                mainScene.attachChild(SkyFactory.createSky(simpleApplication.getAssetManager(),
+                        "Textures/Sky/Bright/BrightSky.dds",
+                        SkyFactory.EnvMapType.CubeMap));
+
+                createTerrain(mainScene, app.getAssetManager());
+
+                DirectionalLight sun = new DirectionalLight();
+                Vector3f lightDir = new Vector3f(-0.37352666f, -0.50444174f, -0.7784704f);
+                sun.setDirection(lightDir);
+                sun.setColor(ColorRGBA.White.clone().multLocal(2));
+                mainScene.addLight(sun);
+
+                rootNode.attachChild(mainScene);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(simpleApplication.getAssetManager(),true);
+
+                FogFilter fog = new FogFilter();
+                fog.setFogColor(new ColorRGBA(0.9f, 0.9f, 0.9f, 1.0f));
+                fog.setFogDistance(155);
+                fog.setFogDensity(1.0f);
+                fpp.addFilter(fog);
+                simpleApplication.getViewPort().addProcessor(fpp);
+            }
+
+
+            private void createTerrain(Node rootNode, AssetManager assetManager) {
+                Material matRock = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md");
+                matRock.setBoolean("useTriPlanarMapping", false);
+                matRock.setBoolean("WardIso", true);
+                matRock.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));
+                Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
+                Texture grass = assetManager.loadTexture("Textures/Terrain/splat/grass.jpg");
+                grass.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap", grass);
+                matRock.setFloat("DiffuseMap_0_scale", 64);
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
+                dirt.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_1", dirt);
+                matRock.setFloat("DiffuseMap_1_scale", 16);
+                Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
+                rock.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_2", rock);
+                matRock.setFloat("DiffuseMap_2_scale", 128);
+                Texture normalMap0 = assetManager.loadTexture("Textures/Terrain/splat/grass_normal.jpg");
+                normalMap0.setWrap(Texture.WrapMode.Repeat);
+                Texture normalMap1 = assetManager.loadTexture("Textures/Terrain/splat/dirt_normal.png");
+                normalMap1.setWrap(Texture.WrapMode.Repeat);
+                Texture normalMap2 = assetManager.loadTexture("Textures/Terrain/splat/road_normal.png");
+                normalMap2.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("NormalMap", normalMap0);
+                matRock.setTexture("NormalMap_1", normalMap1);
+                matRock.setTexture("NormalMap_2", normalMap2);
+
+                AbstractHeightMap heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.25f);
+                heightmap.load();
+
+                TerrainQuad terrain = new TerrainQuad("terrain", 65, 513, heightmap.getHeightMap());
+
+                terrain.setMaterial(matRock);
+                terrain.setLocalScale(new Vector3f(5, 5, 5));
+                terrain.setLocalTranslation(new Vector3f(0, -30, 0));
+                terrain.setLocked(false); // unlock it so we can edit the height
+
+                terrain.setShadowMode(RenderQueue.ShadowMode.Receive);
+                rootNode.attachChild(terrain);
+
+            }
+
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+                System.out.println(getApplication().getCamera().getLocation());
+            }
+
+        }))
         .setFramesToTakeScreenshotsOn(1)
         .run();
     }