Ver Fonte

#2279 Add the initial screenshot test frame work and first few tests

Richard Tingle há 1 ano atrás
pai
commit
b7fde5421d
26 ficheiros alterados com 1948 adições e 1 exclusões
  1. 43 1
      .github/workflows/main.yml
  2. 50 0
      jme3-screenshot-tests/README.md
  3. 39 0
      jme3-screenshot-tests/build.gradle
  4. 20 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java
  5. 68 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java
  6. 31 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/PixelSamenessDegree.java
  7. 328 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotNoInputAppState.java
  8. 85 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java
  9. 14 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java
  10. 416 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  11. 19 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestResolution.java
  12. 19 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestType.java
  13. 256 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestExplosionEffect.java
  14. 232 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestIssue1773.java
  15. 84 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/export/TestOgreConvert.java
  16. 64 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/gui/TestBitmapText3D.java
  17. 178 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/water/TestPostWater.java
  18. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png
  19. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f2.png
  20. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png
  21. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png
  22. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f1.png
  23. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f5.png
  24. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.gui.TestBitmapText3D.testBitmapText3D_f1.png
  25. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater_f1.png
  26. 2 0
      settings.gradle

+ 43 - 1
.github/workflows/main.yml

@@ -56,7 +56,49 @@ on:
     types: [published]
 
 jobs:
-
+  ScreenshotTests:
+    name: Run Screenshot Tests
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up JDK 17
+      uses: actions/setup-java@v4
+      with:
+        java-version: '17'
+        distribution: 'temurin'
+    - name: Install Mesa3D
+      run: |
+        sudo apt-get update
+        sudo apt-get install -y mesa-utils libgl1-mesa-dri libgl1-mesa-glx xvfb
+    - name: Set environment variables for Mesa3D
+      run: |
+        echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV
+        echo "MESA_LOADER_DRIVER_OVERRIDE=llvmpipe" >> $GITHUB_ENV
+    - name: Start xvfb
+      run: |
+        sudo Xvfb :99 -ac -screen 0 1024x768x16 &
+        export DISPLAY=:99
+        echo "DISPLAY=:99" >> $GITHUB_ENV
+    - name: Verify Mesa3D Installation
+      run: |
+        glxinfo | grep "OpenGL"
+    - name: Validate the Gradle wrapper
+      uses: gradle/actions/wrapper-validation@v3
+    - name: Test with Gradle Wrapper
+      run: |
+        ./gradlew :jme3-screenshot-test:screenshotTest
+    - name: Upload Test Reports
+      uses: actions/upload-artifact@master
+      if: always()
+      with:
+        name: screenshot-test-report
+        retention-days: 30
+        path: |
+          **/build/reports/**
+          **/build/changed-images/**
+          **/build/test-results/**
   # Build the natives on android
   BuildAndroidNatives:
     name: Build natives for android

+ 50 - 0
jme3-screenshot-tests/README.md

@@ -0,0 +1,50 @@
+# jme3-screenshot-tests
+
+This module contains tests that compare screenshots of the JME3 test applications to reference images. The tests are run using 
+the following command:
+
+```
+ ./gradlew :jme3-screenshot-test:screenshotTest
+```
+
+This will create a report in `jme3-screenshot-test/build/reports/ScreenshotDiffReport.html` that shows the differences between the reference images and the screenshots taken during the test run. Note that this is an ExtentReport. 
+
+This is most reliable when run on the CI server. The report can be downloaded from the artifacts section of the pipeline (once the full pipeline has completed). If you go into
+the Actions tab (on GitHub) and find your pipeline you can download the report from the Artifacts section. It will be called screenshot-test-report.
+
+## Machine variability
+
+It is important to be aware that the tests are sensitive to machine variability. Different GPUs may produce subtly different pixel outputs
+(that look identical to a human user). The tests are run on a specific machine and the reference images are generated on that machine. If the tests are run on a different machine, the images may not match the reference images and this is "fine". If you run these on your local machine compare the differences by eye in the report, don't wory about failing tests.
+
+## Parameterised tests
+
+By default, the tests use the class and method name to produce the screenshot image name. E.g. org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png is the testExplosionEffect test at frame 15. If you are using parameterised tests this won't work (as all the tests have the same function name). In this case you should specify the image name (including whatever parameterised information to make it unique). E.g.
+
+```
+    screenshotTest(
+        ....
+    ).setFramesToTakeScreenshotsOn(45)
+    .setBaseImageFileName("some_unique_name_" + theParameterGivenToTest)
+    .run();
+)
+```
+
+## Non-deterministic (and known bad) tests
+
+By default, screenshot variability will cause the pipeline to fail. If a test is non-deterministic (e.g. includes randomness) or 
+is a known accepted failure (that will be fixed "at some point" but not now) that can be non-desirable. In that case you can 
+change the behaviour of the test such that these are marked as warnings in the generated report but don't fail the test
+
+```
+    screenshotTest(
+        ....
+    ).setFramesToTakeScreenshotsOn(45)
+    .setTestType(TestType.NON_DETERMINISTIC)
+    .run();
+)
+```
+
+## Accepting new images
+
+It may be the case that a change makes an improvement to the library (or the test is entirely new) and the new image should be accepted as the new reference image. To do this, copy the new image to the `src/test/resources` directory. The new image can be found in the `build/changed-images` directory, however it is very important that the image come from the reference machine. This can be obtained from the CI server. The job runs only if there is an active pull request (to one of the mainline branches; e.g. master or 3.7). If you go into the Actions tab and find your pipeline you can download the report and changed images from the Artifacts section.

+ 39 - 0
jme3-screenshot-tests/build.gradle

@@ -0,0 +1,39 @@
+plugins {
+    id 'java'
+}
+
+repositories {
+    mavenCentral()
+}
+
+dependencies {
+    implementation project(':jme3-desktop')
+    implementation project(':jme3-core')
+    implementation project(':jme3-effects')
+    implementation project(':jme3-terrain')
+    implementation project(':jme3-lwjgl3')
+    implementation project(':jme3-plugins')
+
+    implementation 'com.aventstack:extentreports:5.1.1'
+    implementation platform('org.junit:junit-bom:5.9.1')
+    implementation 'org.junit.jupiter:junit-jupiter'
+    testRuntimeOnly project(':jme3-testdata')
+}
+
+tasks.register("screenshotTest", Test) {
+    useJUnitPlatform{
+        filter{
+            includeTags 'integration'
+        }
+    }
+}
+
+
+test {
+    useJUnitPlatform{
+        filter{
+            excludeTags 'integration'
+        }
+    }
+    jvmArgs = [ "-Xmx1g" ] //already very low on memory available for the 3D renderer, don't let java steal too much of it
+}

+ 20 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java

@@ -0,0 +1,20 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.AppState;
+import com.jme3.app.state.VideoRecorderAppState;
+import com.jme3.math.ColorRGBA;
+
+public class App extends SimpleApplication {
+
+    public App(AppState... initialStates){
+        super(initialStates);
+    }
+
+    @Override
+    public void simpleInitApp(){
+        getViewPort().setBackgroundColor(ColorRGBA.Black);
+        setTimer(new VideoRecorderAppState.IsoTimer(60));
+    }
+
+}

+ 68 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java

@@ -0,0 +1,68 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.aventstack.extentreports.ExtentReports;
+import com.aventstack.extentreports.ExtentTest;
+import com.aventstack.extentreports.reporter.ExtentSparkReporter;
+import com.aventstack.extentreports.reporter.configuration.Theme;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestWatcher;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Optional;
+
+
+public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeTestExecutionCallback{
+    private static ExtentReports extent;
+    private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
+
+    @Override
+    public void beforeAll(ExtensionContext context) {
+        if(extent==null){
+            ExtentSparkReporter spark = new ExtentSparkReporter("build/reports/ScreenshotDiffReport.html");
+            spark.config().setTheme(Theme.STANDARD);
+            spark.config().setDocumentTitle("Screenshot Test Report");
+            spark.config().setReportName("Screenshot Test Report");
+            extent = new ExtentReports();
+            extent.attachReporter(spark);
+        }
+    }
+
+    @Override
+    public void afterAll(ExtensionContext context) {
+        extent.flush();
+    }
+
+    @Override
+    public void testSuccessful(ExtensionContext context) {
+        getCurrentTest().pass("Test passed");
+    }
+
+    @Override
+    public void testFailed(ExtensionContext context, Throwable cause) {
+        getCurrentTest().fail(cause);
+    }
+
+    @Override
+    public void testAborted(ExtensionContext context, Throwable cause) {
+        getCurrentTest().skip("Test aborted " + cause.toString());
+    }
+
+    @Override
+    public void testDisabled(ExtensionContext context, Optional<String> reason) {
+        getCurrentTest().skip("Test disabled: " + reason.orElse("No reason"));
+    }
+
+    @Override
+    public void beforeTestExecution(ExtensionContext context) {
+        String testName = context.getDisplayName();
+        test.set(extent.createTest(testName));
+    }
+
+    public static ExtentTest getCurrentTest() {
+        return test.get();
+    }
+}

+ 31 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/PixelSamenessDegree.java

@@ -0,0 +1,31 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.math.ColorRGBA;
+
+public enum PixelSamenessDegree{
+    SAME(1, null),
+    SUBTLY_DIFFERENT(10, ColorRGBA.Blue),
+
+    MEDIUMLY_DIFFERENT(20, ColorRGBA.Yellow),
+
+    VERY_DIFFERENT(60,ColorRGBA.Orange),
+
+    EXTREMELY_DIFFERENT(100,ColorRGBA.Orange);
+
+    private final int maximumAllowedDifference;
+
+    private final ColorRGBA colorInDebugImage;
+
+    PixelSamenessDegree(int maximumAllowedDifference, ColorRGBA colorInDebugImage){
+        this.colorInDebugImage = colorInDebugImage;
+        this.maximumAllowedDifference = maximumAllowedDifference;
+    }
+
+    public ColorRGBA getColorInDebugImage(){
+        return colorInDebugImage;
+    }
+
+    public int getMaximumAllowedDifference(){
+        return maximumAllowedDifference;
+    }
+}

+ 328 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotNoInputAppState.java

@@ -0,0 +1,328 @@
+/*
+ * Copyright (c) 2009-2021 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.Application;
+import com.jme3.app.state.AbstractAppState;
+import com.jme3.app.state.AppStateManager;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.post.SceneProcessor;
+import com.jme3.profile.AppProfiler;
+import com.jme3.renderer.Camera;
+import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.Renderer;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.system.JmeSystem;
+import com.jme3.texture.FrameBuffer;
+import com.jme3.util.BufferUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This is more or less the same as ScreenshotAppState but without the keyboard input
+ * (because in a headless environment, there is no keyboard and trying to configure it caused
+ * errors).
+ */
+public class ScreenshotNoInputAppState extends AbstractAppState implements ActionListener, SceneProcessor {
+
+    private static final Logger logger = Logger.getLogger(ScreenshotNoInputAppState.class.getName());
+    private String filePath = null;
+    private boolean capture = false;
+    private boolean numbered = true;
+    private Renderer renderer;
+    private RenderManager rm;
+    private ByteBuffer outBuf;
+    private String shotName;
+    private long shotIndex = 0;
+    private int width, height;
+
+    /**
+     * ViewPort to which the SceneProcessor is attached
+     */
+    private ViewPort last;
+
+    /**
+     * Using this constructor, the screenshot files will be written sequentially to the system
+     * default storage folder.
+     */
+    public ScreenshotNoInputAppState() {
+        this(null);
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     */
+    public ScreenshotNoInputAppState(String filePath) {
+        this.filePath = filePath;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param fileName The name of the file to save the screenshot as.
+     */
+    public ScreenshotNoInputAppState(String filePath, String fileName) {
+        this.filePath = filePath;
+        this.shotName = fileName;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot and
+     * a base index for the shot index.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param shotIndex The base index for screenshots.  The first screenshot will have
+     *     shotIndex + 1 appended, the next shotIndex + 2, and so on.
+     */
+    public ScreenshotNoInputAppState(String filePath, long shotIndex) {
+        this.filePath = filePath;
+        this.shotIndex = shotIndex;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot and
+     * a base index for the shot index.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param fileName The name of the file to save the screenshot as.
+     * @param shotIndex The base index for screenshots.  The first screenshot will have
+     *     shotIndex + 1 appended, the next shotIndex + 2, and so on.
+     */
+    public ScreenshotNoInputAppState(String filePath, String fileName, long shotIndex) {
+        this.filePath = filePath;
+        this.shotName = fileName;
+        this.shotIndex = shotIndex;
+    }
+
+    /**
+     * Set the file path to store the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath File path to use to store the screenshot. Include the separator at the end of the path.
+     */
+    public void setFilePath(String filePath) {
+        this.filePath = filePath;
+    }
+
+    /**
+     * Set the file name of the screenshot.
+     * @param fileName File name to save the screenshot as.
+     */
+    public void setFileName(String fileName) {
+        this.shotName = fileName;
+    }
+
+    /**
+     * Sets the base index that will used for subsequent screenshots.
+     *
+     * @param index the desired base index
+     */
+    public void setShotIndex(long index) {
+        this.shotIndex = index;
+    }
+
+    /**
+     * Sets if the filename should be appended with a number representing the
+     * current sequence.
+     * @param numberedWanted If numbering is wanted.
+     */
+    public void setIsNumbered(boolean numberedWanted) {
+        this.numbered = numberedWanted;
+    }
+
+    @Override
+    public void initialize(AppStateManager stateManager, Application app) {
+        if (!super.isInitialized()) {
+            List<ViewPort> vps = app.getRenderManager().getPostViews();
+            last = vps.get(vps.size() - 1);
+            last.addProcessor(this);
+
+            if (shotName == null) {
+                shotName = app.getClass().getSimpleName();
+            }
+        }
+
+        super.initialize(stateManager, app);
+    }
+
+    /**
+     * Clean up this AppState during the first update after it gets detached.
+     * <p>
+     * Because each ScreenshotAppState is also a SceneProcessor (in addition to
+     * being an AppState) this method is also invoked when the SceneProcessor
+     * get removed from its ViewPort, leading to an indirect recursion:
+     * <ol><li>AppStateManager invokes ScreenshotAppState.cleanup()</li>
+     * <li>cleanup() invokes ViewPort.removeProcessor()</li>
+     * <li>removeProcessor() invokes ScreenshotAppState.cleanup()</li>
+     * <li>... and so on.</li>
+     * </ol>
+     * <p>
+     * In order to break this recursion, this method only removes the
+     * SceneProcessor if it has not previously been removed.
+     * <p>
+     * A better design would have the AppState and SceneProcessor be 2 distinct
+     * objects, but doing so now might break applications that rely on them
+     * being a single object.
+     */
+    @Override
+    public void cleanup() {
+        ViewPort viewPort = last;
+        if (viewPort != null) {
+            last = null;
+            viewPort.removeProcessor(this); // XXX indirect recursion!
+        }
+
+        super.cleanup();
+    }
+
+    @Override
+    public void onAction(String name, boolean value, float tpf) {
+        if (value) {
+            capture = true;
+        }
+    }
+
+    public void takeScreenshot() {
+        capture = true;
+    }
+
+    @Override
+    public void initialize(RenderManager rm, ViewPort vp) {
+        renderer = rm.getRenderer();
+        this.rm = rm;
+        reshape(vp, vp.getCamera().getWidth(), vp.getCamera().getHeight());
+    }
+
+    @Override
+    public boolean isInitialized() {
+        return super.isInitialized() && renderer != null;
+    }
+
+    @Override
+    public void reshape(ViewPort vp, int w, int h) {
+        outBuf = BufferUtils.createByteBuffer(w * h * 4);
+        width = w;
+        height = h;
+    }
+
+    @Override
+    public void preFrame(float tpf) {
+        // do nothing
+    }
+
+    @Override
+    public void postQueue(RenderQueue rq) {
+        // do nothing
+    }
+
+    @Override
+    public void postFrame(FrameBuffer out) {
+        if (capture) {
+            capture = false;
+
+            Camera curCamera = rm.getCurrentCamera();
+            int viewX = (int) (curCamera.getViewPortLeft() * curCamera.getWidth());
+            int viewY = (int) (curCamera.getViewPortBottom() * curCamera.getHeight());
+            int viewWidth = (int) ((curCamera.getViewPortRight() - curCamera.getViewPortLeft()) * curCamera.getWidth());
+            int viewHeight = (int) ((curCamera.getViewPortTop() - curCamera.getViewPortBottom()) * curCamera.getHeight());
+
+            renderer.setViewPort(0, 0, width, height);
+            renderer.readFrameBuffer(out, outBuf);
+            renderer.setViewPort(viewX, viewY, viewWidth, viewHeight);
+
+            File file;
+            String filename;
+            if (numbered) {
+                shotIndex++;
+                filename = shotName + shotIndex;
+            } else {
+                filename = shotName;
+            }
+
+            if (filePath == null) {
+                file = new File(JmeSystem.getStorageFolder() + File.separator + filename + ".png").getAbsoluteFile();
+            } else {
+                file = new File(filePath + filename + ".png").getAbsoluteFile();
+            }
+
+            if (logger.isLoggable(Level.FINE)) {
+                logger.log(Level.FINE, "Saving ScreenShot to: {0}", file.getAbsolutePath());
+            }
+
+            try {
+                writeImageFile(file);
+            } catch (IOException ex) {
+                logger.log(Level.SEVERE, "Error while saving screenshot", ex);
+            }
+        }
+    }
+
+    @Override
+    public void setProfiler(AppProfiler profiler) {
+        // not implemented
+    }
+
+    /**
+     * Called by postFrame() once the screen has been captured to outBuf.
+     *
+     * @param file the output file
+     * @throws IOException if an I/O error occurs
+     */
+    protected void writeImageFile(File file) throws IOException {
+        OutputStream outStream = new FileOutputStream(file);
+        try {
+            JmeSystem.writeImageFile(outStream, "png", outBuf, width, height);
+        } finally {
+            outStream.close();
+        }
+    }
+}

+ 85 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java

@@ -0,0 +1,85 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.state.AppState;
+import com.jme3.system.AppSettings;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class ScreenshotTest{
+
+    TestType testType = TestType.MUST_PASS;
+
+    AppState[] states;
+
+    List<Integer> framesToTakeScreenshotsOn = new ArrayList<>();
+
+    TestResolution resolution = new TestResolution(500, 400);
+
+    String baseImageFileName = null;
+
+    public ScreenshotTest(AppState... initialStates){
+        states = initialStates;
+        framesToTakeScreenshotsOn.add(1); //default behaviour is to take a screenshot on the first frame
+    }
+
+    /**
+     * Sets the frames to take screenshots on. Frames are at a hard coded 60 FPS (from JME's perspective, clock time may vary).
+     */
+    public ScreenshotTest setFramesToTakeScreenshotsOn(Integer... frames){
+        framesToTakeScreenshotsOn.clear();
+        framesToTakeScreenshotsOn.addAll(Arrays.asList(frames));
+        return this;
+    }
+
+    /**
+     * Sets the test type (i.e. what the pass/fail rules are for the test
+     */
+    public ScreenshotTest setTestType(TestType testType){
+        this.testType = testType;
+        return this;
+    }
+
+    public ScreenshotTest setTestResolution(TestResolution resolution){
+        this.resolution = resolution;
+        return this;
+    }
+
+    /**
+     * Sets the file name to be used (as the first part) of saved images in both the resources directory and
+     * the generated image. Note that you only have to call this if you want to override the default behaviour which is
+     * to use the calling class and method name, like org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater
+     */
+    public ScreenshotTest setBaseImageFileName(String baseImageFileName){
+        this.baseImageFileName = baseImageFileName;
+        return this;
+    }
+
+    public void run(){
+        AppSettings settings = new AppSettings(true);
+        settings.setResolution(resolution.getWidth(), resolution.getHeight());
+        settings.setAudioRenderer(null); // Disable audio (for headless)
+        settings.setUseInput(false);
+
+        String imageFilePrefix = baseImageFileName == null ? calculateImageFilePrefix() : baseImageFileName;
+
+        TestDriver.bootAppForTest(testType,settings,imageFilePrefix, framesToTakeScreenshotsOn, states);
+    }
+
+
+    private String calculateImageFilePrefix(){
+        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+
+        // The element at index 2 is the caller of this method
+        // (0 is getStackTrace, 1 is getCallerInfo, 2 is the caller of getCallerInfo etc)
+        if (stackTrace.length > 3) {
+            StackTraceElement caller = stackTrace[3];
+            return caller.getClassName() + "." + caller.getMethodName();
+        } else {
+            throw new RuntimeException("Caller information is not available.");
+        }
+    }
+
+
+}

+ 14 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java

@@ -0,0 +1,14 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.state.AppState;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(ExtentReportExtension.class)
+@Tag("integration")
+public abstract class ScreenshotTestBase{
+
+    public ScreenshotTest screenshotTest(AppState... initialStates){
+        return new ScreenshotTest(initialStates);
+    }
+}

+ 416 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java

@@ -0,0 +1,416 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.aventstack.extentreports.ExtentTest;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.AppState;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.math.FastMath;
+import com.jme3.system.AppSettings;
+import com.jme3.system.JmeContext;
+
+import javax.imageio.IIOImage;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageWriteParam;
+import javax.imageio.ImageWriter;
+import javax.imageio.stream.ImageOutputStream;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * The test driver allows for controlled interaction between the thread running the application and the thread
+ * running the test
+ */
+public class TestDriver extends BaseAppState{
+
+    public static final String IMAGES_ARE_DIFFERENT = "Images are different.";
+
+    public static final String IMAGES_ARE_DIFFERENT_SIZES = "Images are different sizes.";
+
+    public static final String KNOWN_BAD_TEST_IMAGES_DIFFERENT = "Images are different. This is a known broken test.";
+
+    public static final String KNOWN_BAD_TEST_IMAGES_SAME = "This is (or was?) a known broken test but it is now passing, please change the test type to MUST_PASS.";
+
+    public static final String NON_DETERMINISTIC_TEST = "This is a non deterministic test, please manually review the expected and actual images to make sure they are approximately the same.";
+
+    private static final Executor executor = Executors.newSingleThreadExecutor( (r) -> {
+        Thread thread = new Thread(r);
+        thread.setDaemon(true);
+        return thread;
+    });
+
+    int tick = 0;
+
+    Collection<Integer> framesToTakeScreenshotsOn;
+
+    ScreenshotNoInputAppState screenshotAppState;
+
+    private final Object waitLock = new Object();
+
+    private final int tickToTerminateApp;
+
+    public TestDriver(ScreenshotNoInputAppState screenshotAppState, Collection<Integer> framesToTakeScreenshotsOn){
+        this.screenshotAppState = screenshotAppState;
+        this.framesToTakeScreenshotsOn = framesToTakeScreenshotsOn;
+        this.tickToTerminateApp = framesToTakeScreenshotsOn.stream().mapToInt(i -> i).max().orElse(0) + 1;
+    }
+
+    @Override
+    public void update(float tpf){
+        super.update(tpf);
+
+        if(framesToTakeScreenshotsOn.contains(tick)){
+            screenshotAppState.takeScreenshot();
+        }
+        if(tick >= tickToTerminateApp){
+            getApplication().stop(true);
+            synchronized (waitLock) {
+                waitLock.notify(); // Release the wait
+            }
+        }
+
+        tick++;
+    }
+
+    @Override protected void initialize(Application app){}
+
+    @Override protected void cleanup(Application app){}
+
+    @Override protected void onEnable(){}
+
+    @Override protected void onDisable(){}
+
+
+    /**
+     * Boots up the application on a separate thread (blocks this thread) and then does the following:
+     * - Takes screenshots on the requested frames
+     * - 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
+        Collections.sort(framesToTakeScreenshotsOn);
+
+        Path 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);
+
+        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);
+
+        executor.execute(() -> app.start(JmeContext.Type.Display));
+
+        synchronized (testDriver.waitLock) {
+            try {
+                testDriver.waitLock.wait(10000); // Wait for the screenshot to be taken and application to stop
+                Thread.sleep(200); //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);
+        }
+
+        //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.");
+        }
+        if(imageFiles.size() != framesToTakeScreenshotsOn.size()){
+            fail("Not all screenshots were taken, expected " + framesToTakeScreenshotsOn.size() + " but got " + imageFiles.size());
+        }
+
+        String failureMessage = null;
+
+        try {
+            for(int screenshotIndex=0;screenshotIndex<framesToTakeScreenshotsOn.size();screenshotIndex++){
+                Path generatedImage = imageFiles.get(screenshotIndex);
+                int frame = framesToTakeScreenshotsOn.get(screenshotIndex);
+
+                String thisFrameBaseImageFileName = baseImageFileName + "_f" + frame;
+
+                Path expectedImage = Paths.get("src/test/resources/" + thisFrameBaseImageFileName + ".png");
+
+                if(!Files.exists(expectedImage)){
+                    try{
+                        Path savedImage = saveGeneratedImageToSavedImages(generatedImage, thisFrameBaseImageFileName);
+                        attachImage("New image:", thisFrameBaseImageFileName + ".png", savedImage);
+                        String message = "Expected image not found, is this a new test? If so collect the new image from the step artefacts";
+                        if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
+                            failureMessage = message;
+                        }
+                        ExtentReportExtension.getCurrentTest().fail(message);
+                        continue;
+                    } catch(IOException e){
+                        throw new RuntimeException(e);
+                    }
+                }
+
+                BufferedImage img1 = ImageIO.read(generatedImage.toFile());
+                BufferedImage img2 = ImageIO.read(expectedImage.toFile());
+
+                if (imagesAreTheSame(img1, img2)) {
+                    if(testType == TestType.KNOWN_TO_FAIL){
+                        ExtentReportExtension.getCurrentTest().warning(KNOWN_BAD_TEST_IMAGES_SAME);
+                    }
+                } else {
+                    //save the generated image to the build directory
+                    Path savedImage = saveGeneratedImageToSavedImages(generatedImage, thisFrameBaseImageFileName);
+
+                    attachImage("Expected", thisFrameBaseImageFileName + "_expected.png", expectedImage);
+                    attachImage("Actual", thisFrameBaseImageFileName + "_actual.png", savedImage);
+                    attachImage("Diff", thisFrameBaseImageFileName + "_diff.png", createComparisonImage(img1, img2));
+
+                    switch(testType){
+                        case MUST_PASS:
+                            if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
+                                failureMessage = IMAGES_ARE_DIFFERENT;
+                            }
+                            ExtentReportExtension.getCurrentTest().fail(IMAGES_ARE_DIFFERENT);
+                            break;
+                        case NON_DETERMINISTIC:
+                            ExtentReportExtension.getCurrentTest().warning(NON_DETERMINISTIC_TEST);
+                            break;
+                        case KNOWN_TO_FAIL:
+                            ExtentReportExtension.getCurrentTest().warning(KNOWN_BAD_TEST_IMAGES_DIFFERENT);
+                            break;
+                    }
+                }
+
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Error reading images", e);
+        } finally{
+            clearTemporaryFolder(imageTempDir);
+        }
+
+        if(failureMessage!=null){
+            fail(failureMessage);
+        }
+    }
+
+    private static void clearTemporaryFolder(Path temporaryFolder){
+        try (Stream<Path> paths = Files.walk(temporaryFolder)) {
+            paths.sorted((a, b) -> b.getNameCount() - a.getNameCount())
+                    .forEach(path -> {
+                        try {
+                            Files.delete(path);
+                        } catch (IOException e) {
+                            throw new RuntimeException(e);
+                        }
+                    });
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static Path saveGeneratedImageToSavedImages(Path generatedImage, String imageFileName) throws IOException{
+        Path savedImage = Paths.get("build/changed-images/" + imageFileName + ".png");
+        Files.createDirectories(savedImage.getParent());
+        Files.copy(generatedImage, savedImage, StandardCopyOption.REPLACE_EXISTING);
+        aggressivelyCompressImage(savedImage);
+        return savedImage;
+    }
+
+    /**
+     * This remains lossless but makes the maximum effort to compress the image. As these images
+     * may be committed to the repository it is important to keep them as small as possible and worth the extra CPU time
+     * to do so
+     */
+    public static void aggressivelyCompressImage(Path path) throws IOException {
+        // Load your image
+        BufferedImage image = ImageIO.read(path.toFile());
+
+        // Get a PNG writer
+        ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next();
+        ImageWriteParam writeParam = writer.getDefaultWriteParam();
+
+        // Increase compression effort
+        writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
+        writeParam.setCompressionQuality(0.0f); // 0.0 means maximum compression
+
+        // Save the image with increased compression
+        try (ImageOutputStream outputStream = ImageIO.createImageOutputStream(path.toFile())) {
+            writer.setOutput(outputStream);
+            writer.write(null, new IIOImage(image, null, null), writeParam);
+        }
+
+        // Clean up
+        writer.dispose();
+    }
+
+    public static void attachImage(String title, String fileName, Path originalImage) throws IOException{
+        ExtentTest test = ExtentReportExtension.getCurrentTest();
+        Files.copy(originalImage.toAbsolutePath(), Paths.get("build/reports/" + fileName), StandardCopyOption.REPLACE_EXISTING);
+        test.addScreenCaptureFromPath(fileName, title);
+    }
+
+    public static void attachImage(String title, String fileName, BufferedImage originalImage) throws IOException{
+        ExtentTest test = ExtentReportExtension.getCurrentTest();
+        ImageIO.write(originalImage, "png", Paths.get("build/reports/" + fileName).toFile());
+        test.addScreenCaptureFromPath(fileName, title);
+    }
+
+    private static boolean imagesAreTheSame(BufferedImage img1, BufferedImage img2) {
+        if (img1.getWidth() != img2.getWidth() || img1.getHeight() != img2.getHeight()) {
+            ExtentReportExtension.getCurrentTest().createNode("Image 1 size : " + img1.getWidth() + "x" + img1.getHeight());
+            ExtentReportExtension.getCurrentTest().createNode("Image 2 size : " + img2.getWidth() + "x" + img2.getHeight());
+            fail(IMAGES_ARE_DIFFERENT_SIZES);
+        }
+
+        for (int y = 0; y < img1.getHeight(); y++) {
+            for (int x = 0; x < img1.getWidth(); x++) {
+                if (!pixelsApproximatelyEqual(img1.getRGB(x, y),img2.getRGB(x, y))) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    private static BufferedImage createComparisonImage(BufferedImage img1, BufferedImage img2) {
+        BufferedImage comparisonImage = new BufferedImage(img1.getWidth(), img1.getHeight(), BufferedImage.TYPE_INT_ARGB);
+
+        for (int y = 0; y < img1.getHeight(); y++) {
+            for (int x = 0; x < img1.getWidth(); x++) {
+                PixelSamenessDegree pixelSameness = categorisePixelDifference(img1.getRGB(x, y),img2.getRGB(x, y));
+
+                if(pixelSameness == PixelSamenessDegree.SAME){
+                    int washedOutPixel = getWashedOutPixel(img1, x, y, 0.9f);
+                    //Color rawColor = new Color(img1.getRGB(x, y), true);
+                    comparisonImage.setRGB(x, y, washedOutPixel);
+                }else{
+                    comparisonImage.setRGB(x, y, pixelSameness.getColorInDebugImage().asIntARGB());
+                }
+            }
+        }
+        return comparisonImage;
+    }
+
+    /**
+     * This produces the almost grey ghost of the original image, used when the differences are being highlighted
+     */
+    public static int getWashedOutPixel(BufferedImage img, int x, int y, float alpha) {
+        // Get the raw pixel value
+        int rgb = img.getRGB(x, y);
+
+        // Extract the color components
+        int a = (rgb >> 24) & 0xFF;
+        int r = (rgb >> 16) & 0xFF;
+        int g = (rgb >> 8) & 0xFF;
+        int b = rgb & 0xFF;
+
+        // Define the overlay gray color (same value for r, g, b)
+        int gray = 128;
+
+        // Blend the original color with the gray color
+        r = (int) ((1 - alpha) * r + alpha * gray);
+        g = (int) ((1 - alpha) * g + alpha * gray);
+        b = (int) ((1 - alpha) * b + alpha * gray);
+
+        // Clamp the values to the range [0, 255]
+        r = Math.min(255, r);
+        g = Math.min(255, g);
+        b = Math.min(255, b);
+
+        // Combine the components back into a single int
+
+        return (a << 24) | (r << 16) | (g << 8) | b;
+    }
+
+    private static boolean pixelsApproximatelyEqual(int pixel1, int pixel2){
+        if(pixel1 == pixel2){
+            return true;
+        }
+
+        int pixelDifference = getMaximumComponentDifference(pixel1, pixel2);
+
+        return pixelDifference <= PixelSamenessDegree.SAME.getMaximumAllowedDifference();
+    }
+
+    private static PixelSamenessDegree categorisePixelDifference(int pixel1, int pixel2){
+        if(pixel1 == pixel2){
+            return PixelSamenessDegree.SAME;
+        }
+
+        int pixelDifference = getMaximumComponentDifference(pixel1, pixel2);
+
+        if(pixelDifference<= PixelSamenessDegree.SAME.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.SAME;
+        }
+        if(pixelDifference<= PixelSamenessDegree.SUBTLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.SUBTLY_DIFFERENT;
+        }
+        if(pixelDifference<= PixelSamenessDegree.MEDIUMLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.MEDIUMLY_DIFFERENT;
+        }
+        if(pixelDifference<= PixelSamenessDegree.VERY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.VERY_DIFFERENT;
+        }
+        return PixelSamenessDegree.EXTREMELY_DIFFERENT;
+    }
+
+    private static int getMaximumComponentDifference(int pixel1, int pixel2){
+        int r1 = (pixel1 >> 16) & 0xFF;
+        int g1 = (pixel1 >> 8) & 0xFF;
+        int b1 = pixel1 & 0xFF;
+        int a1 = (pixel1 >> 24) & 0xFF;
+
+        int r2 = (pixel2 >> 16) & 0xFF;
+        int g2 = (pixel2 >> 8) & 0xFF;
+        int b2 = pixel2 & 0xFF;
+        int a2 = (pixel2 >> 24) & 0xFF;
+
+        return Math.max(Math.abs(r1 - r2), Math.max(Math.abs(g1 - g2), Math.max(Math.abs(b1 - b2), Math.abs(a1 - a2))));
+    }
+
+}

+ 19 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestResolution.java

@@ -0,0 +1,19 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+public class TestResolution{
+    int width;
+    int height;
+
+    public TestResolution(int width, int height){
+        this.width = width;
+        this.height = height;
+    }
+
+    public int getWidth(){
+        return width;
+    }
+
+    public int getHeight(){
+        return height;
+    }
+}

+ 19 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestType.java

@@ -0,0 +1,19 @@
+package org.jmonkeyengine.screenshottests.testframework;
+
+public enum TestType{
+    /**
+     * Normal test, if it fails it will fail the step
+     */
+    MUST_PASS,
+    /**
+     * Test is likely to fail because it is testing something non-deterministic (e.g. uses random numbers).
+     * This will be marked as a warning in the report but the test will not fail.
+     */
+    NON_DETERMINISTIC,
+    /**
+     * Test is known to fail, this will be marked as a warning in the report but the test will not fail.
+     * It will be marked as a warning whether it passes or fails, this is because if it has started to pass again it should
+     * be returned to a normal test with type MUST_PASS.
+     */
+    KNOWN_TO_FAIL,
+}

+ 256 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestExplosionEffect.java

@@ -0,0 +1,256 @@
+package org.jmonkeyengine.screenshottests.effects;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.effect.ParticleEmitter;
+import com.jme3.effect.ParticleMesh;
+import com.jme3.effect.shapes.EmitterSphereShape;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Node;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+public class TestExplosionEffect extends ScreenshotTestBase{
+
+    /**
+     * This test's particle effects (using an explosion)
+     */
+    @Test
+    public void testExplosionEffect(){
+        screenshotTest(new BaseAppState(){
+            private ParticleEmitter flame, flash, spark, roundspark, smoketrail, debris,
+                    shockwave;
+
+            private int state = 0;
+
+            final private Node explosionEffect = new Node("explosionFX");
+
+            @Override
+            protected void initialize(Application app){
+                createFlame();
+                createFlash();
+                createSpark();
+                createRoundSpark();
+                createSmokeTrail();
+                createDebris();
+                createShockwave();
+                explosionEffect.setLocalScale(0.5f);
+                app.getRenderManager().preloadScene(explosionEffect);
+
+                Camera camera = app.getCamera();
+                camera.setLocation(new Vector3f(0, 3.5135868f, 10));
+                camera.setRotation(new Quaternion(1.5714673E-4f, 0.98696727f, -0.16091813f, 9.6381607E-4f));
+                Node rootNode = ((SimpleApplication)app).getRootNode();
+                rootNode.attachChild(explosionEffect);
+            }
+
+            @Override
+            protected void cleanup(Application app){}
+
+            @Override
+            protected void onEnable(){}
+
+            @Override
+            protected void onDisable(){}
+
+            private void createFlame(){
+                flame = new ParticleEmitter("Flame", ParticleMesh.Type.Point, 32);
+                flame.setSelectRandomImage(true);
+                flame.setStartColor(new ColorRGBA(1f, 0.4f, 0.05f, 1f ));
+                flame.setEndColor(new ColorRGBA(.4f, .22f, .12f, 0f));
+                flame.setStartSize(1.3f);
+                flame.setEndSize(2f);
+                flame.setShape(new EmitterSphereShape(Vector3f.ZERO, 1f));
+                flame.setParticlesPerSec(0);
+                flame.setGravity(0, -5, 0);
+                flame.setLowLife(.4f);
+                flame.setHighLife(.5f);
+                flame.getParticleInfluencer().setInitialVelocity(new Vector3f(0, 7, 0));
+                flame.getParticleInfluencer().setVelocityVariation(1f);
+                flame.setImagesX(2);
+                flame.setImagesY(2);
+
+                AssetManager assetManager = getApplication().getAssetManager();
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/flame.png"));
+                mat.setBoolean("PointSprite", true);
+                flame.setMaterial(mat);
+                explosionEffect.attachChild(flame);
+            }
+
+            private void createFlash(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                flash = new ParticleEmitter("Flash", ParticleMesh.Type.Point, 24 );
+                flash.setSelectRandomImage(true);
+                flash.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1f ));
+                flash.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                flash.setStartSize(.1f);
+                flash.setEndSize(3.0f);
+                flash.setShape(new EmitterSphereShape(Vector3f.ZERO, .05f));
+                flash.setParticlesPerSec(0);
+                flash.setGravity(0, 0, 0);
+                flash.setLowLife(.2f);
+                flash.setHighLife(.2f);
+                flash.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 5f, 0));
+                flash.getParticleInfluencer().setVelocityVariation(1);
+                flash.setImagesX(2);
+                flash.setImagesY(2);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/flash.png"));
+                mat.setBoolean("PointSprite", true);
+                flash.setMaterial(mat);
+                explosionEffect.attachChild(flash);
+            }
+
+            private void createRoundSpark(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                roundspark = new ParticleEmitter("RoundSpark", ParticleMesh.Type.Point, 20 );
+                roundspark.setStartColor(new ColorRGBA(1f, 0.29f, 0.34f, (float) (1.0 )));
+                roundspark.setEndColor(new ColorRGBA(0, 0, 0, 0.5f ));
+                roundspark.setStartSize(1.2f);
+                roundspark.setEndSize(1.8f);
+                roundspark.setShape(new EmitterSphereShape(Vector3f.ZERO, 2f));
+                roundspark.setParticlesPerSec(0);
+                roundspark.setGravity(0, -.5f, 0);
+                roundspark.setLowLife(1.8f);
+                roundspark.setHighLife(2f);
+                roundspark.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 3, 0));
+                roundspark.getParticleInfluencer().setVelocityVariation(.5f);
+                roundspark.setImagesX(1);
+                roundspark.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/roundspark.png"));
+                mat.setBoolean("PointSprite", true);
+                roundspark.setMaterial(mat);
+                explosionEffect.attachChild(roundspark);
+            }
+
+            private void createSpark(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                spark = new ParticleEmitter("Spark", ParticleMesh.Type.Triangle, 30 );
+                spark.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1.0f));
+                spark.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                spark.setStartSize(.5f);
+                spark.setEndSize(.5f);
+                spark.setFacingVelocity(true);
+                spark.setParticlesPerSec(0);
+                spark.setGravity(0, 5, 0);
+                spark.setLowLife(1.1f);
+                spark.setHighLife(1.5f);
+                spark.getParticleInfluencer().setInitialVelocity(new Vector3f(0, 20, 0));
+                spark.getParticleInfluencer().setVelocityVariation(1);
+                spark.setImagesX(1);
+                spark.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/spark.png"));
+                spark.setMaterial(mat);
+                explosionEffect.attachChild(spark);
+            }
+
+            private void createSmokeTrail(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                smoketrail = new ParticleEmitter("SmokeTrail", ParticleMesh.Type.Triangle, 22 );
+                smoketrail.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1.0f ));
+                smoketrail.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                smoketrail.setStartSize(.2f);
+                smoketrail.setEndSize(1f);
+
+                smoketrail.setFacingVelocity(true);
+                smoketrail.setParticlesPerSec(0);
+                smoketrail.setGravity(0, 1, 0);
+                smoketrail.setLowLife(.4f);
+                smoketrail.setHighLife(.5f);
+                smoketrail.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 12, 0));
+                smoketrail.getParticleInfluencer().setVelocityVariation(1);
+                smoketrail.setImagesX(1);
+                smoketrail.setImagesY(3);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/smoketrail.png"));
+                smoketrail.setMaterial(mat);
+                explosionEffect.attachChild(smoketrail);
+            }
+
+            private void createDebris(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                debris = new ParticleEmitter("Debris", ParticleMesh.Type.Triangle, 15 );
+                debris.setSelectRandomImage(true);
+                debris.setRandomAngle(true);
+                debris.setRotateSpeed(FastMath.TWO_PI * 4);
+                debris.setStartColor(new ColorRGBA(1f, 0.59f, 0.28f, 1.0f ));
+                debris.setEndColor(new ColorRGBA(.5f, 0.5f, 0.5f, 0f));
+                debris.setStartSize(.2f);
+                debris.setEndSize(.2f);
+
+//        debris.setShape(new EmitterSphereShape(Vector3f.ZERO, .05f));
+                debris.setParticlesPerSec(0);
+                debris.setGravity(0, 12f, 0);
+                debris.setLowLife(1.4f);
+                debris.setHighLife(1.5f);
+                debris.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 15, 0));
+                debris.getParticleInfluencer().setVelocityVariation(.60f);
+                debris.setImagesX(3);
+                debris.setImagesY(3);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/Debris.png"));
+                debris.setMaterial(mat);
+                explosionEffect.attachChild(debris);
+            }
+
+            private void createShockwave(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                shockwave = new ParticleEmitter("Shockwave", ParticleMesh.Type.Triangle, 1);
+//        shockwave.setRandomAngle(true);
+                shockwave.setFaceNormal(Vector3f.UNIT_Y);
+                shockwave.setStartColor(new ColorRGBA(.48f, 0.17f, 0.01f, .8f));
+                shockwave.setEndColor(new ColorRGBA(.48f, 0.17f, 0.01f, 0f));
+
+                shockwave.setStartSize(0f);
+                shockwave.setEndSize(7f);
+
+                shockwave.setParticlesPerSec(0);
+                shockwave.setGravity(0, 0, 0);
+                shockwave.setLowLife(0.5f);
+                shockwave.setHighLife(0.5f);
+                shockwave.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 0, 0));
+                shockwave.getParticleInfluencer().setVelocityVariation(0f);
+                shockwave.setImagesX(1);
+                shockwave.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/shockwave.png"));
+                shockwave.setMaterial(mat);
+                explosionEffect.attachChild(shockwave);
+            }
+            @Override
+            public void update(float tpf){
+                if (state == 0){
+                    flash.emitAllParticles();
+                    spark.emitAllParticles();
+                    smoketrail.emitAllParticles();
+                    debris.emitAllParticles();
+                    shockwave.emitAllParticles();
+                    state++;
+                }
+                if (state == 1){
+                    flame.emitAllParticles();
+                    roundspark.emitAllParticles();
+                    state++;
+                }
+            }
+
+        }).setFramesToTakeScreenshotsOn(2,15)
+          .run();
+    }
+
+}

+ 232 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestIssue1773.java

@@ -0,0 +1,232 @@
+package org.jmonkeyengine.screenshottests.effects;
+
+import com.jme3.animation.LoopMode;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.cinematic.MotionPath;
+import com.jme3.cinematic.events.MotionEvent;
+import com.jme3.effect.ParticleEmitter;
+import com.jme3.effect.ParticleMesh;
+import com.jme3.effect.shapes.EmitterMeshVertexShape;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.material.Materials;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.BloomFilter;
+import com.jme3.post.filters.FXAAFilter;
+import com.jme3.renderer.Camera;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.shape.CenterQuad;
+import com.jme3.scene.shape.Torus;
+import com.jme3.shadow.DirectionalLightShadowFilter;
+import com.jme3.texture.Texture;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Arrays;
+
+public class TestIssue1773 extends ScreenshotTestBase{
+
+    /**
+     * Test case for Issue 1773 (Wrong particle position when using
+     * 'EmitterMeshVertexShape' or 'EmitterMeshFaceShape' and worldSpace
+     * flag equal to true)
+     *
+     * If the test succeeds, the particles will be generated from the vertices
+     * (for EmitterMeshVertexShape) or from the faces (for EmitterMeshFaceShape)
+     * of the torus mesh. If the test fails, the particles will appear in the
+     * center of the torus when worldSpace flag is set to true.
+     *
+     */
+    @ParameterizedTest(name = "Test Issue 1773 (emit in worldSpace = {0})")
+    @ValueSource(booleans = {true, false})
+    public void testIssue1773(boolean worldSpace, TestInfo testInfo){
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + (worldSpace ? "_worldSpace" : "_localSpace");
+
+        screenshotTest(new BaseAppState(){
+            private ParticleEmitter emit;
+            private Node myModel;
+
+            AssetManager assetManager;
+
+            Node rootNode;
+
+            Camera cam;
+
+            ViewPort viewPort;
+
+            @Override
+            public void initialize(Application app) {
+                assetManager = app.getAssetManager();
+                rootNode = ((SimpleApplication)app).getRootNode();
+                cam = app.getCamera();
+                viewPort = app.getViewPort();
+                configCamera();
+                setupLights();
+                setupGround();
+                setupCircle();
+                createMotionControl();
+            }
+
+            @Override
+            protected void cleanup(Application app){}
+
+            @Override
+            protected void onEnable(){}
+
+            @Override
+            protected void onDisable(){}
+
+            /**
+             * Crates particle emitter and adds it to root node.
+             */
+            private void setupCircle() {
+                myModel = new Node("FieryCircle");
+
+                Geometry torus = createTorus(1f);
+                myModel.attachChild(torus);
+
+                emit = createParticleEmitter(torus, true);
+                myModel.attachChild(emit);
+
+                rootNode.attachChild(myModel);
+            }
+
+            /**
+             * Creates torus geometry used for the emitter shape.
+             */
+            private Geometry createTorus(float radius) {
+                float s = radius / 8f;
+                Geometry geo = new Geometry("CircleXZ", new Torus(64, 4, s, radius));
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+                mat.setColor("Color", ColorRGBA.Blue);
+                mat.getAdditionalRenderState().setWireframe(true);
+                geo.setMaterial(mat);
+                return geo;
+            }
+
+            /**
+             * Creates a particle emitter that will emit the particles from
+             * the given shape's vertices.
+             */
+            private ParticleEmitter createParticleEmitter(Geometry geo, boolean pointSprite) {
+                ParticleMesh.Type type = pointSprite ? ParticleMesh.Type.Point : ParticleMesh.Type.Triangle;
+                ParticleEmitter emitter = new ParticleEmitter("Emitter", type, 1000);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Smoke/Smoke.png"));
+                mat.setBoolean("PointSprite", pointSprite);
+                emitter.setMaterial(mat);
+                emitter.setLowLife(1);
+                emitter.setHighLife(1);
+                emitter.setImagesX(15);
+                emitter.setStartSize(0.04f);
+                emitter.setEndSize(0.02f);
+                emitter.setStartColor(ColorRGBA.Orange);
+                emitter.setEndColor(ColorRGBA.Red);
+                emitter.setParticlesPerSec(900);
+                emitter.setGravity(0, 0f, 0);
+                //emitter.getParticleInfluencer().setVelocityVariation(1);
+                //emitter.getParticleInfluencer().setInitialVelocity(new Vector3f(0, .5f, 0));
+                emitter.setShape(new EmitterMeshVertexShape(Arrays.asList(geo.getMesh())));
+                emitter.setInWorldSpace(worldSpace);
+                //emitter.setShape(new EmitterMeshFaceShape(Arrays.asList(geo.getMesh())));
+                return emitter;
+            }
+
+            /**
+             * Creates a motion control that will move particle emitter in
+             * a circular path.
+             */
+            private void createMotionControl() {
+
+                float radius = 5f;
+                float height = 1.10f;
+
+                MotionPath path = new MotionPath();
+                path.setCycle(true);
+
+                for (int i = 0; i < 8; i++) {
+                    float x = FastMath.sin(FastMath.QUARTER_PI * i) * radius;
+                    float z = FastMath.cos(FastMath.QUARTER_PI * i) * radius;
+                    path.addWayPoint(new Vector3f(x, height, z));
+                }
+                MotionEvent motionControl = new MotionEvent(myModel, path);
+                motionControl.setLoopMode(LoopMode.Loop);
+                motionControl.setDirectionType(MotionEvent.Direction.Path);
+                motionControl.play();
+            }
+
+            private void configCamera() {
+                cam.setLocation(new Vector3f(0, 6f, 9.2f));
+                cam.lookAt(Vector3f.UNIT_Y, Vector3f.UNIT_Y);
+
+                float aspect = (float) cam.getWidth() / cam.getHeight();
+                cam.setFrustumPerspective(45, aspect, 0.1f, 1000f);
+            }
+
+            /**
+             * Adds a ground to the scene
+             */
+            private void setupGround() {
+                CenterQuad quad = new CenterQuad(12, 12);
+                quad.scaleTextureCoordinates(new Vector2f(2, 2));
+                Geometry floor = new Geometry("Floor", quad);
+                Material mat = new Material(assetManager, Materials.LIGHTING);
+                Texture tex = assetManager.loadTexture("Interface/Logo/Monkey.jpg");
+                tex.setWrap(Texture.WrapMode.Repeat);
+                mat.setTexture("DiffuseMap", tex);
+                floor.setMaterial(mat);
+                floor.rotate(-FastMath.HALF_PI, 0, 0);
+                rootNode.attachChild(floor);
+            }
+
+            /**
+             * Adds lights and filters
+             */
+            private void setupLights() {
+                viewPort.setBackgroundColor(ColorRGBA.DarkGray);
+                rootNode.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
+
+                AmbientLight ambient = new AmbientLight();
+                ambient.setColor(ColorRGBA.White);
+                //rootNode.addLight(ambient);
+
+                DirectionalLight sun = new DirectionalLight();
+                sun.setDirection((new Vector3f(-0.5f, -0.5f, -0.5f)).normalizeLocal());
+                sun.setColor(ColorRGBA.White);
+                rootNode.addLight(sun);
+
+                DirectionalLightShadowFilter dlsf = new DirectionalLightShadowFilter(assetManager, 4096, 3);
+                dlsf.setLight(sun);
+                dlsf.setShadowIntensity(0.4f);
+                dlsf.setShadowZExtend(256);
+
+                FXAAFilter fxaa = new FXAAFilter();
+                BloomFilter bloom = new BloomFilter(BloomFilter.GlowMode.Objects);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+                fpp.addFilter(bloom);
+                fpp.addFilter(dlsf);
+                fpp.addFilter(fxaa);
+                viewPort.addProcessor(fpp);
+            }
+
+
+        }).setFramesToTakeScreenshotsOn(45)
+          .setBaseImageFileName(imageName)
+          .run();
+    }
+}

+ 84 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/export/TestOgreConvert.java

@@ -0,0 +1,84 @@
+package org.jmonkeyengine.screenshottests.export;
+
+import com.jme3.anim.AnimComposer;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.export.binary.BinaryExporter;
+import com.jme3.export.binary.BinaryImporter;
+import com.jme3.light.DirectionalLight;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+public class TestOgreConvert extends ScreenshotTestBase{
+
+    /**
+     * This tests loads an Ogre model, converts it to binary, and then reloads it.
+     * <p>
+     * Note that the model is animated and the animation is played back. That is why
+     * two screenshots are taken
+     * </p>
+     */
+    @Test
+    public void testOgreConvert(){
+
+        screenshotTest(
+                new BaseAppState(){
+                    @Override
+                    protected void initialize(Application app){
+                        AssetManager assetManager = app.getAssetManager();
+                        Node rootNode = ((SimpleApplication)app).getRootNode();
+                        Camera cam = app.getCamera();
+                        Spatial ogreModel = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+
+                        DirectionalLight dl = new DirectionalLight();
+                        dl.setColor(ColorRGBA.White);
+                        dl.setDirection(new Vector3f(0,-1,-1).normalizeLocal());
+                        rootNode.addLight(dl);
+
+                        cam.setLocation(new Vector3f(0, 0, 15));
+
+                        try {
+                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                            BinaryExporter exp = new BinaryExporter();
+                            exp.save(ogreModel, baos);
+
+                            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
+                            BinaryImporter imp = new BinaryImporter();
+                            imp.setAssetManager(assetManager);
+                            Node ogreModelReloaded = (Node) imp.load(bais, null, null);
+
+                            AnimComposer composer = ogreModelReloaded.getControl(AnimComposer.class);
+                            composer.setCurrentAction("Walk");
+
+                            rootNode.attachChild(ogreModelReloaded);
+                        } catch (IOException ex){
+                            throw new RuntimeException(ex);
+                        }
+                    }
+
+                    @Override
+                    protected void cleanup(Application app){}
+
+                    @Override
+                    protected void onEnable(){}
+
+                    @Override
+                    protected void onDisable(){}
+                }
+        )
+        .setFramesToTakeScreenshotsOn(1, 5)
+        .run();
+
+    }
+}

+ 64 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/gui/TestBitmapText3D.java

@@ -0,0 +1,64 @@
+package org.jmonkeyengine.screenshottests.gui;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.font.BitmapFont;
+import com.jme3.font.BitmapText;
+import com.jme3.font.Rectangle;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.shape.Quad;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+public class TestBitmapText3D extends ScreenshotTestBase{
+
+    /**
+     * This tests both that bitmap text is rendered correctly and that it is
+     * wrapped correctly.
+     */
+    @Test
+    public void testBitmapText3D(){
+
+        screenshotTest(
+                new BaseAppState(){
+                    @Override
+                    protected void initialize(Application app){
+                        String txtB = "ABCDEFGHIKLMNOPQRSTUVWXYZ1234567890`~!@#$%^&*()-=_+[]\\;',./{}|:<>?";
+
+                        AssetManager assetManager = app.getAssetManager();
+                        Node rootNode = ((SimpleApplication)app).getRootNode();
+
+                        Quad q = new Quad(6, 3);
+                        Geometry g = new Geometry("quad", q);
+                        g.setLocalTranslation(-1.5f, -3, -0.0001f);
+                        g.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
+                        rootNode.attachChild(g);
+
+                        BitmapFont fnt = assetManager.loadFont("Interface/Fonts/Default.fnt");
+                        BitmapText txt = new BitmapText(fnt);
+                        txt.setBox(new Rectangle(0, 0, 6, 3));
+                        txt.setQueueBucket(RenderQueue.Bucket.Transparent);
+                        txt.setSize( 0.5f );
+                        txt.setText(txtB);
+                        txt.setLocalTranslation(-1.5f,0,0);
+                        rootNode.attachChild(txt);
+                    }
+
+                    @Override
+                    protected void cleanup(Application app){}
+
+                    @Override
+                    protected void onEnable(){}
+
+                    @Override
+                    protected void onDisable(){}
+                }
+        ).run();
+
+
+    }
+}

+ 178 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/water/TestPostWater.java

@@ -0,0 +1,178 @@
+package org.jmonkeyengine.screenshottests.water;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.BloomFilter;
+import com.jme3.post.filters.DepthOfFieldFilter;
+import com.jme3.post.filters.FXAAFilter;
+import com.jme3.post.filters.LightScatteringFilter;
+import com.jme3.renderer.queue.RenderQueue.ShadowMode;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Texture;
+import com.jme3.texture.Texture.WrapMode;
+import com.jme3.texture.Texture2D;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.SkyFactory.EnvMapType;
+import com.jme3.water.WaterFilter;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+public class TestPostWater extends ScreenshotTestBase{
+
+    /**
+     * This test creates a scene with a terrain and post process water filter.
+     */
+    @Test
+    public void testPostWater(){
+       screenshotTest(new BaseAppState(){
+           @Override
+            protected void initialize(Application app){
+                Vector3f lightDir = new Vector3f(-4.9236743f, -1.27054665f, 5.896916f);
+                SimpleApplication simpleApplication = ((SimpleApplication)app);
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                Node mainScene = new Node("Main Scene");
+                rootNode.attachChild(mainScene);
+
+                createTerrain(mainScene, assetManager);
+                DirectionalLight sun = new DirectionalLight();
+                sun.setDirection(lightDir);
+                sun.setColor(ColorRGBA.White.clone().multLocal(1f));
+                mainScene.addLight(sun);
+
+                AmbientLight al = new AmbientLight();
+                al.setColor(new ColorRGBA(0.1f, 0.1f, 0.1f, 1.0f));
+                mainScene.addLight(al);
+
+
+                simpleApplication.getCamera().setLocation(new Vector3f(-370.31592f, 182.04016f, 196.81192f));
+                simpleApplication.getCamera().setRotation(new Quaternion(0.10058216f, 0.51807004f, -0.061508257f, 0.8471738f));
+
+                Spatial sky = SkyFactory.createSky(assetManager,
+                        "Scenes/Beach/FullskiesSunset0068.dds", EnvMapType.CubeMap);
+                sky.setLocalScale(350);
+
+                mainScene.attachChild(sky);
+                simpleApplication.getCamera().setFrustumFar(4000);
+
+                //Water Filter
+                WaterFilter water = new WaterFilter(rootNode, lightDir);
+                water.setWaterColor(new ColorRGBA().setAsSrgb(0.0078f, 0.3176f, 0.5f, 1.0f));
+                water.setDeepWaterColor(new ColorRGBA().setAsSrgb(0.0039f, 0.00196f, 0.145f, 1.0f));
+                water.setUnderWaterFogDistance(80);
+                water.setWaterTransparency(0.12f);
+                water.setFoamIntensity(0.4f);
+                water.setFoamHardness(0.3f);
+                water.setFoamExistence(new Vector3f(0.8f, 8f, 1f));
+                water.setReflectionDisplace(50);
+                water.setRefractionConstant(0.25f);
+                water.setColorExtinction(new Vector3f(30, 50, 70));
+                water.setCausticsIntensity(0.4f);
+                water.setWaveScale(0.003f);
+                water.setMaxAmplitude(2f);
+                water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam2.jpg"));
+                water.setRefractionStrength(0.2f);
+                //0.8f;
+                float initialWaterHeight = 90f;
+                water.setWaterHeight(initialWaterHeight);
+
+                //Bloom Filter
+                BloomFilter bloom = new BloomFilter();
+                bloom.setExposurePower(55);
+                bloom.setBloomIntensity(1.0f);
+
+                //Light Scattering Filter
+                LightScatteringFilter lsf = new LightScatteringFilter(lightDir.mult(-300));
+                lsf.setLightDensity(0.5f);
+
+                //Depth of field Filter
+                DepthOfFieldFilter dof = new DepthOfFieldFilter();
+                dof.setFocusDistance(0);
+                dof.setFocusRange(100);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+
+                fpp.addFilter(water);
+                fpp.addFilter(bloom);
+                fpp.addFilter(dof);
+                fpp.addFilter(lsf);
+                fpp.addFilter(new FXAAFilter());
+
+                int numSamples = simpleApplication.getContext().getSettings().getSamples();
+                if (numSamples > 0) {
+                    fpp.setNumSamples(numSamples);
+                }
+                simpleApplication.getViewPort().addProcessor(fpp);
+            }
+
+            @Override protected void cleanup(Application app){}
+
+            @Override protected void onEnable(){}
+
+            @Override protected void onDisable(){}
+
+            @Override
+            public void update(float tpf){
+                super.update(tpf);
+            }
+
+            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(WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap", grass);
+                matRock.setFloat("DiffuseMap_0_scale", 64);
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
+                dirt.setWrap(WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_1", dirt);
+                matRock.setFloat("DiffuseMap_1_scale", 16);
+                Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
+                rock.setWrap(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(WrapMode.Repeat);
+                Texture normalMap1 = assetManager.loadTexture("Textures/Terrain/splat/dirt_normal.png");
+                normalMap1.setWrap(WrapMode.Repeat);
+                Texture normalMap2 = assetManager.loadTexture("Textures/Terrain/splat/road_normal.png");
+                normalMap2.setWrap(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(ShadowMode.Receive);
+                rootNode.attachChild(terrain);
+            }
+        }).run();
+    }
+
+}

BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f2.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.gui.TestBitmapText3D.testBitmapText3D_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater_f1.png


+ 2 - 0
settings.gradle

@@ -41,3 +41,5 @@ include 'jme3-awt-dialogs'
 if(buildAndroidExamples == "true"){
     include 'jme3-android-examples'
 }
+include 'jme3-screenshot-tests'
+