فهرست منبع

Merge pull request #2280 from richardTingle/#2279-screenshot-tests

#2279 screenshot tests
Ryan McDonough 8 ماه پیش
والد
کامیت
9da10799ba
26فایلهای تغییر یافته به همراه2417 افزوده شده و 1 حذف شده
  1. 43 1
      .github/workflows/main.yml
  2. 50 0
      jme3-screenshot-tests/README.md
  3. 38 0
      jme3-screenshot-tests/build.gradle
  4. 55 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java
  5. 105 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java
  6. 66 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/PixelSamenessDegree.java
  7. 331 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotNoInputAppState.java
  8. 120 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java
  9. 56 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java
  10. 458 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  11. 56 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestResolution.java
  12. 53 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestType.java
  13. 290 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestExplosionEffect.java
  14. 266 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestIssue1773.java
  15. 118 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/export/TestOgreConvert.java
  16. 98 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/gui/TestBitmapText3D.java
  17. 212 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 libglx-mesa0 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.

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

@@ -0,0 +1,38 @@
+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'
+        }
+    }
+}

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

@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2024 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.SimpleApplication;
+import com.jme3.app.state.AppState;
+import com.jme3.app.state.VideoRecorderAppState;
+import com.jme3.math.ColorRGBA;
+
+/**
+ * The app used for the tests. AppState(s) are used to inject the actual test code.
+ * @author Richard Tingle (aka richtea)
+ */
+public class App extends SimpleApplication {
+
+    public App(AppState... initialStates){
+        super(initialStates);
+    }
+
+    @Override
+    public void simpleInitApp(){
+        getViewPort().setBackgroundColor(ColorRGBA.Black);
+        setTimer(new VideoRecorderAppState.IsoTimer(60));
+    }
+
+}

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

@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2024 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.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.Optional;
+
+/**
+ * This creates the Extent report and manages the test lifecycle
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+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) {
+        /*
+        * this writes the entire report after each test class. This sucks but I don't think there is
+        * anywhere else I can hook into the lifecycle of the end of all tests to write the report.
+        */
+        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();
+    }
+}

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

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2024 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.math.ColorRGBA;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public enum PixelSamenessDegree{
+    SAME(1, null),
+    NEGLIGIBLY_DIFFERENT(1, ColorRGBA.Green),
+    SUBTLY_DIFFERENT(10, ColorRGBA.Blue),
+
+    MEDIUMLY_DIFFERENT(20, ColorRGBA.Yellow),
+
+    VERY_DIFFERENT(60,ColorRGBA.Orange),
+
+    EXTREMELY_DIFFERENT(100,ColorRGBA.Red);
+
+    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;
+    }
+}

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

@@ -0,0 +1,331 @@
+/*
+ * Copyright (c) 2024 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).
+ *
+ * @author Richard Tingle (aka richtea)
+ *
+ */
+public class ScreenshotNoInputAppState extends AbstractAppState implements ActionListener, SceneProcessor {
+
+    private static final Logger logger = Logger.getLogger(ScreenshotNoInputAppState.class.getName());
+    private String filePath;
+    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();
+        }
+    }
+}

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

@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2024 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.state.AppState;
+import com.jme3.system.AppSettings;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * This is how a test is configured and started. It uses a fluent API.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+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); //while it will run with inputs on it causes non-fatal errors.
+
+        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, so at 3 should be the test class
+        if (stackTrace.length > 3) {
+            StackTraceElement caller = stackTrace[3];
+            return caller.getClassName() + "." + caller.getMethodName();
+        } else {
+            throw new RuntimeException("Caller information is not available.");
+        }
+    }
+
+
+}

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

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 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.state.AppState;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * This is the base class for all screenshot tests
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+@ExtendWith(ExtentReportExtension.class)
+@Tag("integration")
+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
+     * @return
+     */
+    public ScreenshotTest screenshotTest(AppState... initialStates){
+        return new ScreenshotTest(initialStates);
+    }
+}

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

@@ -0,0 +1,458 @@
+/*
+ * Copyright (c) 2024 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.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 injects the screenshot taking into the application lifecycle, pauses the main test thread until the
+ * screenshots have been taken and then compares the screenshots to the expected images.
+ *
+ * @author Richard Tingle (aka richtea)
+ *
+ */
+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 = saveGeneratedImageToChangedImages(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 = saveGeneratedImageToChangedImages(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);
+        }
+    }
+
+    /**
+     * Saves the image with the exact file name it needs to go into the resources directory to be a new reference image
+     * if the instigator of the change wants to accept this as the new "correct" state.
+     */
+    private static Path saveGeneratedImageToChangedImages(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
+     */
+    private 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();
+    }
+
+    /**
+     * Attaches the image to the report. A copy of the image is made in the report directory
+     */
+    private 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);
+    }
+
+    /**
+     * Attaches the image to the report. The image is written to the report directory
+     */
+    private 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);
+    }
+
+    /**
+     * Tests that the images are the same. If they are not the same it will return false (which may fail the test
+     * depending on the test type). Different sizes are so fatal that they will immediately fail the test.
+     */
+    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 (img1.getRGB(x, y)  != img2.getRGB(x, y)){
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Creates an image that highlights the differences between the two images. The reference image is shown
+     * dully in grey with blue, yellow, orange and red showing where pixels are different.
+     */
+    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 PixelSamenessDegree categorisePixelDifference(int pixel1, int pixel2){
+        if(pixel1 == pixel2){
+            return PixelSamenessDegree.SAME;
+        }
+
+        int pixelDifference = getMaximumComponentDifference(pixel1, pixel2);
+
+        if(pixelDifference<= PixelSamenessDegree.NEGLIGIBLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.NEGLIGIBLY_DIFFERENT;
+        }
+        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))));
+    }
+
+}

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

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 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;
+
+/**
+ * The size the test should be run at. Try to keep it small to reduce the file size of the screenshots saved into the
+ * repository.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+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;
+    }
+}

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

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2024 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;
+
+/**
+ * Controls pass/fail behaviour of a test. Default behaviour is MUST_PASS.
+ */
+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,
+}

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

@@ -0,0 +1,290 @@
+/*
+ * Copyright (c) 2024 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.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;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+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();
+    }
+
+}

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

@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2024 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.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;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+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();
+    }
+}

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

@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2024 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.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;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+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();
+
+    }
+}

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

@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2024 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.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;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+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();
+
+
+    }
+}

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

@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2024 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.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;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+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'
+