Просмотр исходного кода

Merge branch 'jMonkeyEngine:master' into capdevon-TestReverb

Wyatt Gillette 4 месяцев назад
Родитель
Сommit
64d08f9dd7
34 измененных файлов с 1973 добавлено и 180 удалено
  1. 117 0
      .github/workflows/screenshot-test-comment.yml
  2. 1 1
      gradle.properties
  3. 23 4
      jme3-core/src/main/java/com/jme3/audio/Filter.java
  4. 105 13
      jme3-core/src/main/java/com/jme3/audio/Listener.java
  5. 60 2
      jme3-core/src/main/java/com/jme3/audio/LowPassFilter.java
  6. 48 0
      jme3-core/src/test/java/com/jme3/audio/AudioNodeTest.java
  7. 171 158
      jme3-examples/src/main/java/jme3test/water/TestPostWater.java
  8. 3 2
      jme3-screenshot-tests/README.md
  9. 143 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestIssue2076.java
  10. 188 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestMotionPath.java
  11. 192 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRLighting.java
  12. 138 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSimple.java
  13. 125 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/material/TestSimpleBumps.java
  14. 291 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrain.java
  15. 368 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrainAdvanced.java
  16. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestIssue2076.testIssue2076_f1.png
  17. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestMotionPath.testMotionPath_f10.png
  18. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestMotionPath.testMotionPath_f60.png
  19. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_DefaultDirectionalLight_f12.png
  20. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_HighRoughness_f12.png
  21. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_LowRoughness_f12.png
  22. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_UpdatedDirectionalLight_f12.png
  23. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithRealtimeBaking_f10.png
  24. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithoutRealtimeBaking_f10.png
  25. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.material.TestSimpleBumps.testSimpleBumps_f10.png
  26. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.material.TestSimpleBumps.testSimpleBumps_f60.png
  27. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_FinalRender_f5.png
  28. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_GeometryNormals_f5.png
  29. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_MetallicMap_f5.png
  30. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_NormalMap_f5.png
  31. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_RoughnessMap_f5.png
  32. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_AmbientOcclusion_f5.png
  33. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_Emissive_f5.png
  34. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_FinalRender_f5.png

+ 117 - 0
.github/workflows/screenshot-test-comment.yml

@@ -0,0 +1,117 @@
+name: Screenshot Test PR Comment
+
+# This workflow is designed to safely comment on PRs from forks
+# It uses pull_request_target which has higher permissions than pull_request
+# Security note: This workflow does NOT check out or execute code from the PR
+# It only monitors the status of the ScreenshotTests job and posts comments
+# (If this commenting was done in the main worflow it would not have the permissions
+# to create a comment)
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, reopened]
+
+jobs:
+  monitor-screenshot-tests:
+    name: Monitor Screenshot Tests and Comment
+    runs-on: ubuntu-latest
+    timeout-minutes: 60
+    permissions:
+      pull-requests: write
+      contents: read
+    steps:
+      - name: Wait for GitHub to register the workflow run
+        run: sleep 15
+
+      - name: Wait for Screenshot Tests to complete
+        uses: lewagon/[email protected]
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+          check-name: 'Run Screenshot Tests'
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          wait-interval: 10
+          allowed-conclusions: success,skipped,failure
+      - name: Check Screenshot Tests status
+        id: check-status
+        uses: actions/github-script@v6
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          script: |
+            const { owner, repo } = context.repo;
+            const ref = '${{ github.event.pull_request.head.sha }}';
+
+            // Get workflow runs for the PR
+            const runs = await github.rest.actions.listWorkflowRunsForRepo({
+              owner,
+              repo,
+              head_sha: ref
+            });
+
+            // Find the ScreenshotTests job
+            let screenshotTestRun = null;
+            for (const run of runs.data.workflow_runs) {
+              if (run.name === 'Build jMonkeyEngine') {
+                const jobs = await github.rest.actions.listJobsForWorkflowRun({
+                  owner,
+                  repo,
+                  run_id: run.id
+                });
+
+                for (const job of jobs.data.jobs) {
+                  if (job.name === 'Run Screenshot Tests') {
+                    screenshotTestRun = job;
+                    break;
+                  }
+                }
+
+                if (screenshotTestRun) break;
+              }
+            }
+
+            if (!screenshotTestRun) {
+              console.log('Screenshot test job not found');
+              return;
+            }
+
+            // Check if the job failed
+            if (screenshotTestRun.conclusion === 'failure') {
+              core.setOutput('failed', 'true');
+            } else {
+              core.setOutput('failed', 'false');
+            }
+      - name: Find Existing Comment
+        uses: peter-evans/find-comment@v3
+        id: existingCommentId
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          comment-author: 'github-actions[bot]'
+          body-includes: Screenshot tests have failed.
+
+      - name: Comment on PR if tests fail
+        if: steps.check-status.outputs.failed == 'true'
+        uses: peter-evans/create-or-update-comment@v4
+        with:
+          issue-number: ${{ github.event.pull_request.number }}
+          body: |
+            🖼️ **Screenshot tests have failed.** 
+
+            The purpose of these tests is to ensure that changes introduced in this PR don't break visual features. They are visual unit tests.
+
+            📄 **Where to find the report:**
+            - Go to the (failed run) > Summary > Artifacts > screenshot-test-report
+            - Download the zip and open jme3-screenshot-tests/build/reports/ScreenshotDiffReport.html
+
+            ⚠️ **If you didn't expect to change anything visual:** 
+            Fix your changes so the screenshot tests pass.
+
+            ✅ **If you did mean to change things:** 
+            Review the replacement images in jme3-screenshot-tests/build/changed-images to make sure they really are improvements and then replace and commit the replacement images at jme3-screenshot-tests/src/test/resources.
+
+            ✨ **If you are creating entirely new tests:** 
+            Find the new images in jme3-screenshot-tests/build/changed-images and commit the new images at jme3-screenshot-tests/src/test/resources.
+
+            **Note;**  it is very important that the committed reference images are created on the build pipeline, locally created images are not reliable. Similarly tests will fail locally but you can look at the report to check they are "visually similar".
+
+            See https://github.com/jMonkeyEngine/jmonkeyengine/blob/master/jme3-screenshot-tests/README.md for more information
+          edit-mode: replace
+          comment-id: ${{ steps.existingCommentId.outputs.comment-id }}

+ 1 - 1
gradle.properties

@@ -1,5 +1,5 @@
 # Version number: Major.Minor.SubMinor (e.g. 3.3.0)
-jmeVersion = 3.8.0
+jmeVersion = 3.9.0
 
 # Leave empty to autogenerate
 # (use -PjmeVersionName="myVersion" from commandline to specify a custom version name )

+ 23 - 4
jme3-core/src/main/java/com/jme3/audio/Filter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2020 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -35,9 +35,12 @@ import com.jme3.export.JmeExporter;
 import com.jme3.export.JmeImporter;
 import com.jme3.export.Savable;
 import com.jme3.util.NativeObject;
+import com.jme3.util.clone.Cloner;
+import com.jme3.util.clone.JmeCloneable;
+
 import java.io.IOException;
 
-public abstract class Filter extends NativeObject implements Savable {
+public abstract class Filter extends NativeObject implements Savable, JmeCloneable {
 
     public Filter() {
         super();
@@ -49,12 +52,28 @@ public abstract class Filter extends NativeObject implements Savable {
 
     @Override
     public void write(JmeExporter ex) throws IOException {
-        // nothing to save
+        // no-op
     }
 
     @Override
     public void read(JmeImporter im) throws IOException {
-        // nothing to read
+        // no-op
+    }
+
+    /**
+     *  Called internally by com.jme3.util.clone.Cloner.  Do not call directly.
+     */
+    @Override
+    public Object jmeClone() {
+        return super.clone();
+    }
+
+    /**
+     * Called internally by com.jme3.util.clone.Cloner. Do not call directly.
+     */
+    @Override
+    public void cloneFields(Cloner cloner, Object original) {
+        // no-op
     }
 
     @Override

+ 105 - 13
jme3-core/src/main/java/com/jme3/audio/Listener.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -34,6 +34,11 @@ package com.jme3.audio;
 import com.jme3.math.Quaternion;
 import com.jme3.math.Vector3f;
 
+/**
+ * Represents the audio listener in the 3D sound scene.
+ * The listener defines the point of view from which sound is heard,
+ * influencing spatial audio effects like panning and Doppler shift.
+ */
 public class Listener {
 
     private final Vector3f location;
@@ -42,72 +47,159 @@ public class Listener {
     private float volume = 1;
     private AudioRenderer renderer;
 
+    /**
+     * Constructs a new {@code Listener} with default parameters.
+     */
     public Listener() {
         location = new Vector3f();
         velocity = new Vector3f();
         rotation = new Quaternion();
     }
 
+    /**
+     * Constructs a new {@code Listener} by copying the properties of another {@code Listener}.
+     *
+     * @param source The {@code Listener} to copy the properties from.
+     */
     public Listener(Listener source) {
-        location = source.location.clone();
-        velocity = source.velocity.clone();
-        rotation = source.rotation.clone();
-        volume = source.volume;
+        this.location = source.location.clone();
+        this.velocity = source.velocity.clone();
+        this.rotation = source.rotation.clone();
+        this.volume = source.volume;
+        this.renderer = source.renderer; // Note: Renderer is also copied
     }
 
+    /**
+     * Sets the {@link AudioRenderer} associated with this listener.
+     * The renderer is responsible for applying the listener's parameters
+     * to the audio output.
+     *
+     * @param renderer The {@link AudioRenderer} to associate with.
+     */
     public void setRenderer(AudioRenderer renderer) {
         this.renderer = renderer;
     }
 
+    /**
+     * Gets the current volume of the listener.
+     *
+     * @return The current volume.
+     */
     public float getVolume() {
         return volume;
     }
 
+    /**
+     * Sets the volume of the listener.
+     * If an {@link AudioRenderer} is set, it will be notified of the volume change.
+     *
+     * @param volume The new volume.
+     */
     public void setVolume(float volume) {
         this.volume = volume;
-        if (renderer != null)
-            renderer.updateListenerParam(this, ListenerParam.Volume);
+        updateListenerParam(ListenerParam.Volume);
     }
 
+    /**
+     * Gets the current location of the listener in world space.
+     *
+     * @return The listener's location as a {@link Vector3f}.
+     */
     public Vector3f getLocation() {
         return location;
     }
 
+    /**
+     * Gets the current rotation of the listener in world space.
+     *
+     * @return The listener's rotation as a {@link Quaternion}.
+     */
     public Quaternion getRotation() {
         return rotation;
     }
 
+    /**
+     * Gets the current velocity of the listener.
+     * This is used for Doppler effect calculations.
+     *
+     * @return The listener's velocity as a {@link Vector3f}.
+     */
     public Vector3f getVelocity() {
         return velocity;
     }
 
+    /**
+     * Gets the left direction vector of the listener.
+     * This vector is derived from the listener's rotation.
+     *
+     * @return The listener's left direction as a {@link Vector3f}.
+     */
     public Vector3f getLeft() {
         return rotation.getRotationColumn(0);
     }
 
+    /**
+     * Gets the up direction vector of the listener.
+     * This vector is derived from the listener's rotation.
+     *
+     * @return The listener's up direction as a {@link Vector3f}.
+     */
     public Vector3f getUp() {
         return rotation.getRotationColumn(1);
     }
 
+    /**
+     * Gets the forward direction vector of the listener.
+     * This vector is derived from the listener's rotation.
+     *
+     * @return The listener's forward direction.
+     */
     public Vector3f getDirection() {
         return rotation.getRotationColumn(2);
     }
 
+    /**
+     * Sets the location of the listener in world space.
+     * If an {@link AudioRenderer} is set, it will be notified of the position change.
+     *
+     * @param location The new location of the listener.
+     */
     public void setLocation(Vector3f location) {
         this.location.set(location);
-        if (renderer != null)
-            renderer.updateListenerParam(this, ListenerParam.Position);
+        updateListenerParam(ListenerParam.Position);
     }
 
+    /**
+     * Sets the rotation of the listener in world space.
+     * If an {@link AudioRenderer} is set, it will be notified of the rotation change.
+     *
+     * @param rotation The new rotation of the listener.
+     */
     public void setRotation(Quaternion rotation) {
         this.rotation.set(rotation);
-        if (renderer != null)
-            renderer.updateListenerParam(this, ListenerParam.Rotation);
+        updateListenerParam(ListenerParam.Rotation);
     }
 
+    /**
+     * Sets the velocity of the listener.
+     * This is used for Doppler effect calculations.
+     * If an {@link AudioRenderer} is set, it will be notified of the velocity change.
+     *
+     * @param velocity The new velocity of the listener.
+     */
     public void setVelocity(Vector3f velocity) {
         this.velocity.set(velocity);
-        if (renderer != null)
-            renderer.updateListenerParam(this, ListenerParam.Velocity);
+        updateListenerParam(ListenerParam.Velocity);
+    }
+
+    /**
+     * Updates the associated {@link AudioRenderer} with the specified listener parameter.
+     *
+     * @param param The {@link ListenerParam} to update on the renderer.
+     */
+    private void updateListenerParam(ListenerParam param) {
+        if (renderer != null) {
+            renderer.updateListenerParam(this, param);
+        }
     }
 }

+ 60 - 2
jme3-core/src/main/java/com/jme3/audio/LowPassFilter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2023 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -36,26 +36,63 @@ import com.jme3.export.JmeExporter;
 import com.jme3.export.JmeImporter;
 import com.jme3.export.OutputCapsule;
 import com.jme3.util.NativeObject;
+
 import java.io.IOException;
 
+/**
+ * A filter that attenuates frequencies above a specified threshold, allowing lower
+ * frequencies to pass through with less attenuation. Commonly used to simulate effects
+ * such as muffling or underwater acoustics.
+ */
 public class LowPassFilter extends Filter {
 
-    protected float volume, highFreqVolume;
+    /**
+     * The overall volume scaling of the filtered sound
+     */
+    protected float volume;
+    /**
+     * The volume scaling of the high frequencies allowed to pass through. Valid values range
+     * from 0.0 to 1.0, where 0.0 completely eliminates high frequencies and 1.0 lets them pass
+     * through unchanged.
+     */
+    protected float highFreqVolume;
 
+    /**
+     * Constructs a low-pass filter.
+     *
+     * @param volume         the overall volume scaling of the filtered sound (0.0 - 1.0).
+     * @param highFreqVolume the volume scaling of high frequencies (0.0 - 1.0).
+     * @throws IllegalArgumentException if {@code volume} or {@code highFreqVolume} is out of range.
+     */
     public LowPassFilter(float volume, float highFreqVolume) {
         super();
         setVolume(volume);
         setHighFreqVolume(highFreqVolume);
     }
 
+    /**
+     * For internal cloning
+     * @param id the native object ID
+     */
     protected LowPassFilter(int id) {
         super(id);
     }
 
+    /**
+     * Retrieves the current volume scaling of high frequencies.
+     *
+     * @return the high-frequency volume scaling.
+     */
     public float getHighFreqVolume() {
         return highFreqVolume;
     }
 
+    /**
+     * Sets the high-frequency volume.
+     *
+     * @param highFreqVolume the new high-frequency volume scaling (0.0 - 1.0).
+     * @throws IllegalArgumentException if {@code highFreqVolume} is out of range.
+     */
     public void setHighFreqVolume(float highFreqVolume) {
         if (highFreqVolume < 0 || highFreqVolume > 1)
             throw new IllegalArgumentException("High freq volume must be between 0 and 1");
@@ -64,10 +101,21 @@ public class LowPassFilter extends Filter {
         this.updateNeeded = true;
     }
 
+    /**
+     * Retrieves the current overall volume scaling of the filtered sound.
+     *
+     * @return the overall volume scaling.
+     */
     public float getVolume() {
         return volume;
     }
 
+    /**
+     * Sets the overall volume.
+     *
+     * @param volume the new overall volume scaling (0.0 - 1.0).
+     * @throws IllegalArgumentException if {@code volume} is out of range.
+     */
     public void setVolume(float volume) {
         if (volume < 0 || volume > 1)
             throw new IllegalArgumentException("Volume must be between 0 and 1");
@@ -92,11 +140,21 @@ public class LowPassFilter extends Filter {
         highFreqVolume = ic.readFloat("hf_volume", 0);
     }
 
+    /**
+     * Creates a native object clone of this filter for internal usage.
+     *
+     * @return a new {@code LowPassFilter} instance with the same native ID.
+     */
     @Override
     public NativeObject createDestructableClone() {
         return new LowPassFilter(id);
     }
 
+    /**
+     * Retrieves a unique identifier for this filter. Used internally for native object management.
+     *
+     * @return a unique long identifier.
+     */
     @Override
     public long getUniqueId() {
         return ((long) OBJTYPE_FILTER << 32) | (0xffffffffL & (long) id);

+ 48 - 0
jme3-core/src/test/java/com/jme3/audio/AudioNodeTest.java

@@ -0,0 +1,48 @@
+package com.jme3.audio;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.math.Vector3f;
+import com.jme3.system.JmeSystem;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Automated tests for the {@code AudioNode} class.
+ *
+ * @author capdevon
+ */
+public class AudioNodeTest {
+
+    @Test
+    public void testAudioNodeClone() {
+        AssetManager assetManager = JmeSystem.newAssetManager(AudioNodeTest.class.getResource("/com/jme3/asset/Desktop.cfg"));
+
+        AudioNode audio = new AudioNode(assetManager,
+                "Sound/Effects/Bang.wav", AudioData.DataType.Buffer);
+        audio.setDirection(new Vector3f(0, 1, 0));
+        audio.setVelocity(new Vector3f(1, 1, 1));
+        audio.setDryFilter(new LowPassFilter(1f, .1f));
+        audio.setReverbFilter(new LowPassFilter(.5f, .5f));
+
+        AudioNode clone = audio.clone();
+
+        Assert.assertNotNull(clone.previousWorldTranslation);
+        Assert.assertNotSame(audio.previousWorldTranslation, clone.previousWorldTranslation);
+        Assert.assertEquals(audio.previousWorldTranslation, clone.previousWorldTranslation);
+
+        Assert.assertNotNull(clone.getDirection());
+        Assert.assertNotSame(audio.getDirection(), clone.getDirection());
+        Assert.assertEquals(audio.getDirection(), clone.getDirection());
+
+        Assert.assertNotNull(clone.getVelocity());
+        Assert.assertNotSame(audio.getVelocity(), clone.getVelocity());
+        Assert.assertEquals(audio.getVelocity(), clone.getVelocity());
+
+        Assert.assertNotNull(clone.getDryFilter());
+        Assert.assertNotSame(audio.getDryFilter(), clone.getDryFilter());
+
+        Assert.assertNotNull(clone.getReverbFilter());
+        Assert.assertNotSame(audio.getReverbFilter(), clone.getReverbFilter());
+    }
+
+}

+ 171 - 158
jme3-examples/src/main/java/jme3test/water/TestPostWater.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2022 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -40,6 +40,7 @@ import com.jme3.font.BitmapText;
 import com.jme3.input.KeyInput;
 import com.jme3.input.controls.ActionListener;
 import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.controls.Trigger;
 import com.jme3.light.AmbientLight;
 import com.jme3.light.DirectionalLight;
 import com.jme3.material.Material;
@@ -55,7 +56,9 @@ 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.TerrainLodControl;
 import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
 import com.jme3.terrain.heightmap.AbstractHeightMap;
 import com.jme3.terrain.heightmap.ImageBasedHeightMap;
 import com.jme3.texture.Texture;
@@ -66,18 +69,12 @@ import com.jme3.util.SkyFactory.EnvMapType;
 import com.jme3.water.WaterFilter;
 
 /**
- * test
- *
  * @author normenhansen
  */
 public class TestPostWater extends SimpleApplication {
 
-    final private Vector3f lightDir = new Vector3f(-4.9236743f, -1.27054665f, 5.896916f);
+    private final Vector3f lightDir = new Vector3f(-4.9236743f, -1.27054665f, 5.896916f);
     private WaterFilter water;
-    private AudioNode waves;
-    final private LowPassFilter aboveWaterAudioFilter = new LowPassFilter(1, 1);
-    final private Filter underWaterAudioFilter = new LowPassFilter(0.5f, 0.1f);
-    private boolean useDryFilter = true;
 
     public static void main(String[] args) {
         TestPostWater app = new TestPostWater();
@@ -87,173 +84,154 @@ public class TestPostWater extends SimpleApplication {
     @Override
     public void simpleInitApp() {
 
-        setDisplayFps(false);
-        setDisplayStatView(false);
-        
         Node mainScene = new Node("Main Scene");
         rootNode.attachChild(mainScene);
 
+        configureCamera();
+        createSky(mainScene);
         createTerrain(mainScene);
+        createLights(mainScene);
+        createWaterFilter();
+        setupPostFilters();
+        addAudioClip();
+        setupUI();
+        registerInputMappings();
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(50f);
+        cam.setLocation(new Vector3f(-370.31592f, 182.04016f, 196.81192f));
+        cam.setRotation(new Quaternion(0.015302252f, 0.9304095f, -0.039101653f, 0.3641086f));
+        cam.setFrustumFar(2000);
+    }
+
+    private void createLights(Node mainScene) {
         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);
-        
-        flyCam.setMoveSpeed(50);
+    }
 
-        //cam.setLocation(new Vector3f(-700, 100, 300));
-        //cam.setRotation(new Quaternion().fromAngleAxis(0.5f, Vector3f.UNIT_Z));
-//        cam.setLocation(new Vector3f(-327.21957f, 61.6459f, 126.884346f));
-//        cam.setRotation(new Quaternion(0.052168474f, 0.9443102f, -0.18395276f, 0.2678024f));
+    private void createSky(Node mainScene) {
+        Spatial sky = SkyFactory.createSky(assetManager,
+                "Scenes/Beach/FullskiesSunset0068.dds", EnvMapType.CubeMap);
+        sky.setShadowMode(ShadowMode.Off);
+        mainScene.attachChild(sky);
+    }
 
+    private void setupUI() {
+        setText(0, 50, "1 - Set Foam Texture to Foam.jpg");
+        setText(0, 80, "2 - Set Foam Texture to Foam2.jpg");
+        setText(0, 110, "3 - Set Foam Texture to Foam3.jpg");
+        setText(0, 140, "4 - Turn Dry Filter under water On/Off");
+        setText(0, 240, "PgUp - Larger Reflection Map");
+        setText(0, 270, "PgDn - Smaller Reflection Map");
+    }
 
-        cam.setLocation(new Vector3f(-370.31592f, 182.04016f, 196.81192f));
-        cam.setRotation(new Quaternion(0.015302252f, 0.9304095f, -0.039101653f, 0.3641086f));
+    private void setText(int x, int y, String text) {
+        BitmapText bmp = new BitmapText(guiFont);
+        bmp.setText(text);
+        bmp.setLocalTranslation(x, cam.getHeight() - y, 0);
+        bmp.setColor(ColorRGBA.Red);
+        guiNode.attachChild(bmp);
+    }
 
+    private void registerInputMappings() {
+        addMapping("foam1", new KeyTrigger(KeyInput.KEY_1));
+        addMapping("foam2", new KeyTrigger(KeyInput.KEY_2));
+        addMapping("foam3", new KeyTrigger(KeyInput.KEY_3));
+        addMapping("dryFilter", new KeyTrigger(KeyInput.KEY_4));
+        addMapping("upRM", new KeyTrigger(KeyInput.KEY_PGUP));
+        addMapping("downRM", new KeyTrigger(KeyInput.KEY_PGDN));
+    }
 
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(actionListener, mappingName);
+    }
 
+    private final ActionListener actionListener = new ActionListener() {
+        @Override
+        public void onAction(String name, boolean isPressed, float tpf) {
+            if (!isPressed) return;
 
-        Spatial sky = SkyFactory.createSky(assetManager, 
-                "Scenes/Beach/FullskiesSunset0068.dds", EnvMapType.CubeMap);
-        sky.setLocalScale(350);
+            if (name.equals("foam1")) {
+                water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam.jpg"));
 
-        mainScene.attachChild(sky);
-        cam.setFrustumFar(4000);
+            } else if (name.equals("foam2")) {
+                water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam2.jpg"));
 
-        //Water Filter
-        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);
-        water.setWaterHeight(initialWaterHeight);
-        
-        //Bloom Filter
-        BloomFilter bloom = new BloomFilter();        
+            } else if (name.equals("foam3")) {
+                water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam3.jpg"));
+
+            } else if (name.equals("upRM")) {
+                water.setReflectionMapSize(Math.min(water.getReflectionMapSize() * 2, 4096));
+                System.out.println("Reflection map size : " + water.getReflectionMapSize());
+
+            } else if (name.equals("downRM")) {
+                water.setReflectionMapSize(Math.max(water.getReflectionMapSize() / 2, 32));
+                System.out.println("Reflection map size : " + water.getReflectionMapSize());
+
+            } else if (name.equals("dryFilter")) {
+                useDryFilter = !useDryFilter;
+            }
+        }
+    };
+
+    private void setupPostFilters() {
+        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
+        lsf.setLightDensity(0.5f);
+
         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());
-        
-//      fpp.addFilter(new TranslucentBucketFilter());
+
         int numSamples = getContext().getSettings().getSamples();
         if (numSamples > 0) {
             fpp.setNumSamples(numSamples);
         }
-
-        
-        uw = cam.getLocation().y < waterHeight;
-
-        waves = new AudioNode(assetManager, "Sound/Environment/Ocean Waves.ogg",
-                DataType.Buffer);
-        waves.setLooping(true);
-        updateAudio();
-        audioRenderer.playSource(waves);
-        //  
         viewPort.addProcessor(fpp);
+    }
 
-        setText(0, 50, "1 - Set Foam Texture to Foam.jpg");
-        setText(0, 80, "2 - Set Foam Texture to Foam2.jpg");
-        setText(0, 110, "3 - Set Foam Texture to Foam3.jpg");
-        setText(0, 140, "4 - Turn Dry Filter under water On/Off");
-        setText(0, 240, "PgUp - Larger Reflection Map");
-        setText(0, 270, "PgDn - Smaller Reflection Map");
-
-        inputManager.addListener(new ActionListener() {
-            @Override
-            public void onAction(String name, boolean isPressed, float tpf) {
-                if (isPressed) {
-                    if (name.equals("foam1")) {
-                        water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam.jpg"));
-                    }
-                    if (name.equals("foam2")) {
-                        water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam2.jpg"));
-                    }
-                    if (name.equals("foam3")) {
-                        water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam3.jpg"));
-                    }
-
-                    if (name.equals("upRM")) {
-                        water.setReflectionMapSize(Math.min(water.getReflectionMapSize() * 2, 4096));
-                        System.out.println("Reflection map size : " + water.getReflectionMapSize());
-                    }
-                    if (name.equals("downRM")) {
-                        water.setReflectionMapSize(Math.max(water.getReflectionMapSize() / 2, 32));
-                        System.out.println("Reflection map size : " + water.getReflectionMapSize());
-                    }
-                    if (name.equals("dryFilter")) {
-                        useDryFilter = !useDryFilter; 
-                    }
-                }
-            }
-        }, "foam1", "foam2", "foam3", "upRM", "downRM", "dryFilter");
-        inputManager.addMapping("foam1", new KeyTrigger(KeyInput.KEY_1));
-        inputManager.addMapping("foam2", new KeyTrigger(KeyInput.KEY_2));
-        inputManager.addMapping("foam3", new KeyTrigger(KeyInput.KEY_3));
-        inputManager.addMapping("dryFilter", new KeyTrigger(KeyInput.KEY_4));
-        inputManager.addMapping("upRM", new KeyTrigger(KeyInput.KEY_PGUP));
-        inputManager.addMapping("downRM", new KeyTrigger(KeyInput.KEY_PGDN));
+    private void createWaterFilter() {
+        //Water Filter
+        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);
+        water.setWaterHeight(initialWaterHeight);
     }
 
-    private void createTerrain(Node rootNode) {
-        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);
+    private void createTerrain(Node mainScene) {
+        Material matRock = createTerrainMaterial();
 
+        Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
         AbstractHeightMap heightmap = null;
         try {
             heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.25f);
@@ -261,51 +239,86 @@ public class TestPostWater extends SimpleApplication {
         } catch (Exception e) {
             e.printStackTrace();
         }
-        TerrainQuad terrain
-                = new TerrainQuad("terrain", 65, 513, heightmap.getHeightMap());
+
+        int patchSize = 64;
+        int totalSize = 512;
+        TerrainQuad terrain = new TerrainQuad("terrain", patchSize + 1, totalSize + 1, heightmap.getHeightMap());
+        TerrainLodControl control = new TerrainLodControl(terrain, getCamera());
+        control.setLodCalculator(new DistanceLodCalculator(patchSize + 1, 2.7f)); // patch size, and a multiplier
+        terrain.addControl(control);
         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.setLocalScale(new Vector3f(5, 5, 5));
 
         terrain.setShadowMode(ShadowMode.Receive);
-        rootNode.attachChild(terrain);
+        mainScene.attachChild(terrain);
+    }
+
+    private Material createTerrainMaterial() {
+        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"));
+
+        setTexture("Textures/Terrain/splat/grass.jpg", matRock, "DiffuseMap");
+        setTexture("Textures/Terrain/splat/dirt.jpg", matRock, "DiffuseMap_1");
+        setTexture("Textures/Terrain/splat/road.jpg", matRock, "DiffuseMap_2");
+        matRock.setFloat("DiffuseMap_0_scale", 64);
+        matRock.setFloat("DiffuseMap_1_scale", 16);
+        matRock.setFloat("DiffuseMap_2_scale", 128);
+
+        setTexture("Textures/Terrain/splat/grass_normal.jpg", matRock, "NormalMap");
+        setTexture("Textures/Terrain/splat/dirt_normal.png", matRock, "NormalMap_1");
+        setTexture("Textures/Terrain/splat/road_normal.png", matRock, "NormalMap_2");
 
+        return matRock;
     }
-    //This part is to emulate tides, slightly varying the height of the water plane
+
+    private void setTexture(String texture, Material mat, String param) {
+        Texture tex = assetManager.loadTexture(texture);
+        tex.setWrap(WrapMode.Repeat);
+        mat.setTexture(param, tex);
+    }
+
+    // This part is to emulate tides, slightly varying the height of the water plane
     private float time = 0.0f;
     private float waterHeight = 0.0f;
-    final private float initialWaterHeight = 90f;//0.8f;
-    private boolean uw = false;
+    private final float initialWaterHeight = 90f;
+    private boolean underWater = false;
+
+    private AudioNode waves;
+    private final LowPassFilter aboveWaterAudioFilter = new LowPassFilter(1, 1);
+    private final LowPassFilter underWaterAudioFilter = new LowPassFilter(0.5f, 0.1f);
+    private boolean useDryFilter = true;
 
     @Override
     public void simpleUpdate(float tpf) {
-        super.simpleUpdate(tpf);
-        //     box.updateGeometricState();
         time += tpf;
         waterHeight = (float) Math.cos(((time * 0.6f) % FastMath.TWO_PI)) * 1.5f;
         water.setWaterHeight(initialWaterHeight + waterHeight);
-        uw = water.isUnderWater();
+        underWater = water.isUnderWater();
         updateAudio();
     }
-    
-    protected void setText(int x, int y, String text) {
-        BitmapText txt2 = new BitmapText(guiFont);
-        txt2.setText(text);
-        txt2.setLocalTranslation(x, cam.getHeight() - y, 0);
-        txt2.setColor(ColorRGBA.Red);
-        guiNode.attachChild(txt2);
+
+    private void addAudioClip() {
+        underWater = cam.getLocation().y < waterHeight;
+
+        waves = new AudioNode(assetManager, "Sound/Environment/Ocean Waves.ogg", DataType.Buffer);
+        waves.setLooping(true);
+        updateAudio();
+        waves.play();
     }
 
     /**
      * Update the audio settings (dry filter and reverb)
-     * based on boolean fields ({@code uw} and {@code useDryFilter}).
+     * based on boolean fields ({@code underWater} and {@code useDryFilter}).
      */
-    protected void updateAudio() {
+    private void updateAudio() {
         Filter newDryFilter;
         if (!useDryFilter) {
             newDryFilter = null;
-        } else if (uw) {
+        } else if (underWater) {
             newDryFilter = underWaterAudioFilter;
         } else {
             newDryFilter = aboveWaterAudioFilter;
@@ -316,11 +329,11 @@ public class TestPostWater extends SimpleApplication {
             waves.setDryFilter(newDryFilter);
         }
 
-        boolean newReverbEnabled = !uw;
+        boolean newReverbEnabled = !underWater;
         boolean oldReverbEnabled = waves.isReverbEnabled();
         if (oldReverbEnabled != newReverbEnabled) {
             System.out.println("reverb enabled : " + newReverbEnabled);
             waves.setReverbEnabled(newReverbEnabled);
         }
     }
-}
+}

+ 3 - 2
jme3-screenshot-tests/README.md

@@ -1,7 +1,8 @@
 # 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:
+This module contains tests that compare screenshots of the JME3 test applications to reference images. Think of these like visual unit tests
+
+The tests are run using the following command:
 
 ```
  ./gradlew :jme3-screenshot-test:screenshotTest

+ 143 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestIssue2076.java

@@ -0,0 +1,143 @@
+/*
+ * 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.animation;
+
+import com.jme3.anim.SkinningControl;
+import com.jme3.anim.util.AnimMigrationUtils;
+import com.jme3.animation.SkeletonControl;
+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.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.VertexBuffer;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for JMonkeyEngine issue #2076: software skinning requires vertex
+ * normals.
+ *
+ * <p>If the issue is resolved, 2 copies of the Jaime model will be rendered in the screenshot.
+ *
+ * <p>If the issue is present, then the application will immediately crash,
+ * typically with a {@code NullPointerException}.
+ *
+ * @author Stephen Gold (original test)
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestIssue2076 extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with two Jaime models, one using the old animation system
+     * and one using the new animation system, both with software skinning and no vertex normals.
+     */
+    @Test
+    public void testIssue2076() {
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Add ambient light
+                AmbientLight ambientLight = new AmbientLight();
+                ambientLight.setColor(new ColorRGBA(1f, 1f, 1f, 1f));
+                rootNode.addLight(ambientLight);
+
+                /*
+                 * The original Jaime model was chosen for testing because it includes
+                 * tangent buffers (needed to trigger issue #2076) and uses the old
+                 * animation system (so it can be easily used to test both systems).
+                 */
+                String assetPath = "Models/Jaime/Jaime.j3o";
+
+                // Test old animation system
+                Node oldJaime = (Node) assetManager.loadModel(assetPath);
+                rootNode.attachChild(oldJaime);
+                oldJaime.setLocalTranslation(-1f, 0f, 0f);
+
+                // Enable software skinning
+                SkeletonControl skeletonControl = oldJaime.getControl(SkeletonControl.class);
+                skeletonControl.setHardwareSkinningPreferred(false);
+
+                // Remove its vertex normals
+                Geometry oldGeometry = (Geometry) oldJaime.getChild(0);
+                Mesh oldMesh = oldGeometry.getMesh();
+                oldMesh.clearBuffer(VertexBuffer.Type.Normal);
+                oldMesh.clearBuffer(VertexBuffer.Type.BindPoseNormal);
+
+                // Test new animation system
+                Node newJaime = (Node) assetManager.loadModel(assetPath);
+                AnimMigrationUtils.migrate(newJaime);
+                rootNode.attachChild(newJaime);
+                newJaime.setLocalTranslation(1f, 0f, 0f);
+
+                // Enable software skinning
+                SkinningControl skinningControl = newJaime.getControl(SkinningControl.class);
+                skinningControl.setHardwareSkinningPreferred(false);
+
+                // Remove its vertex normals
+                Geometry newGeometry = (Geometry) newJaime.getChild(0);
+                Mesh newMesh = newGeometry.getMesh();
+                newMesh.clearBuffer(VertexBuffer.Type.Normal);
+                newMesh.clearBuffer(VertexBuffer.Type.BindPoseNormal);
+
+                // Position the camera to see both models
+                simpleApplication.getCamera().setLocation(new Vector3f(0f, 0f, 5f));
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+            }
+        }).run();
+    }
+}

+ 188 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestMotionPath.java

@@ -0,0 +1,188 @@
+/*
+ * 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.animation;
+
+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.MotionPathListener;
+import com.jme3.cinematic.events.MotionEvent;
+import com.jme3.font.BitmapText;
+import com.jme3.input.ChaseCamera;
+import com.jme3.light.DirectionalLight;
+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.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.shape.Box;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the MotionPath functionality.
+ * 
+ * <p>This test creates a teapot model that follows a predefined path with several waypoints.
+ * The animation is automatically started and screenshots are taken at frames 10 and 60
+ * to capture the teapot at different positions along the path.
+ *
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestMotionPath extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a teapot following a motion path.
+     */
+    @Test
+    public void testMotionPath() {
+        screenshotTest(new BaseAppState() {
+            private Spatial teapot;
+            private MotionPath path;
+            private MotionEvent motionControl;
+            private BitmapText wayPointsText;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                Node guiNode = simpleApplication.getGuiNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Set camera position
+                app.getCamera().setLocation(new Vector3f(8.4399185f, 11.189463f, 14.267577f));
+
+                // Create the scene
+                createScene(rootNode, assetManager);
+
+                // Create the motion path
+                path = new MotionPath();
+                path.addWayPoint(new Vector3f(10, 3, 0));
+                path.addWayPoint(new Vector3f(10, 3, 10));
+                path.addWayPoint(new Vector3f(-40, 3, 10));
+                path.addWayPoint(new Vector3f(-40, 3, 0));
+                path.addWayPoint(new Vector3f(-40, 8, 0));
+                path.addWayPoint(new Vector3f(10, 8, 0));
+                path.addWayPoint(new Vector3f(10, 8, 10));
+                path.addWayPoint(new Vector3f(15, 8, 10));
+                path.enableDebugShape(assetManager, rootNode);
+
+                // Create the motion event
+                motionControl = new MotionEvent(teapot, path);
+                motionControl.setDirectionType(MotionEvent.Direction.PathAndRotation);
+                motionControl.setRotation(new Quaternion().fromAngleNormalAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y));
+                motionControl.setInitialDuration(10f);
+                motionControl.setSpeed(2f);
+
+                // Create text for waypoint notifications
+                wayPointsText = new BitmapText(assetManager.loadFont("Interface/Fonts/Default.fnt"));
+                wayPointsText.setSize(wayPointsText.getFont().getCharSet().getRenderedSize());
+                guiNode.attachChild(wayPointsText);
+
+                // Add listener for waypoint events
+                path.addListener(new MotionPathListener() {
+                    @Override
+                    public void onWayPointReach(MotionEvent control, int wayPointIndex) {
+                        if (path.getNbWayPoints() == wayPointIndex + 1) {
+                            wayPointsText.setText(control.getSpatial().getName() + " Finished!!! ");
+                        } else {
+                            wayPointsText.setText(control.getSpatial().getName() + " Reached way point " + wayPointIndex);
+                        }
+                        wayPointsText.setLocalTranslation(
+                                (app.getCamera().getWidth() - wayPointsText.getLineWidth()) / 2,
+                                app.getCamera().getHeight(),
+                                0);
+                    }
+                });
+
+                // note that the ChaseCamera is self-initialising, so just creating this object attaches it
+                new ChaseCamera(getApplication().getCamera(), teapot);
+
+                // Start the animation automatically
+                motionControl.play();
+            }
+
+            private void createScene(Node rootNode, AssetManager assetManager) {
+                // Create materials
+                Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
+                mat.setFloat("Shininess", 1f);
+                mat.setBoolean("UseMaterialColors", true);
+                mat.setColor("Ambient", ColorRGBA.Black);
+                mat.setColor("Diffuse", ColorRGBA.DarkGray);
+                mat.setColor("Specular", ColorRGBA.White.mult(0.6f));
+
+                Material matSoil = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
+                matSoil.setBoolean("UseMaterialColors", true);
+                matSoil.setColor("Ambient", ColorRGBA.Black);
+                matSoil.setColor("Diffuse", ColorRGBA.Black);
+                matSoil.setColor("Specular", ColorRGBA.Black);
+
+                // Create teapot
+                teapot = assetManager.loadModel("Models/Teapot/Teapot.obj");
+                teapot.setName("Teapot");
+                teapot.setLocalScale(3);
+                teapot.setMaterial(mat);
+                rootNode.attachChild(teapot);
+
+                // Create ground
+                Geometry soil = new Geometry("soil", new Box(50, 1, 50));
+                soil.setLocalTranslation(0, -1, 0);
+                soil.setMaterial(matSoil);
+                rootNode.attachChild(soil);
+
+                // Add light
+                DirectionalLight light = new DirectionalLight();
+                light.setDirection(new Vector3f(0, -1, 0).normalizeLocal());
+                light.setColor(ColorRGBA.White.mult(1.5f));
+                rootNode.addLight(light);
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+        })
+        .setFramesToTakeScreenshotsOn(10, 60)
+        .run();
+    }
+}

+ 192 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRLighting.java

@@ -0,0 +1,192 @@
+/*
+ * 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.light.pbr;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.environment.EnvironmentCamera;
+import com.jme3.environment.FastLightProbeFactory;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.LightProbe;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.ToneMapFilter;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.texture.plugins.ktx.KTXLoader;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * Screenshot tests for PBR lighting.
+ *
+ * @author nehon - original test
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ *
+ */
+public class TestPBRLighting extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("LowRoughness", 0.1f, false),
+            Arguments.of("HighRoughness", 1.0f, false),
+            Arguments.of("DefaultDirectionalLight", 0.5f, false),
+            Arguments.of("UpdatedDirectionalLight", 0.5f, true)
+        );
+    }
+
+    /**
+     * Test PBR lighting with different parameters
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param roughness The roughness value to use
+     * @param updateLight Whether to update the directional light to match camera direction
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRLighting(String testName, float roughness, boolean updateLight, TestInfo testInfo) {
+
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private static final int RESOLUTION = 256;
+
+            private Node modelNode;
+            private int frame = 0;
+
+            @Override
+            protected void initialize(Application app) {
+                Camera cam = app.getCamera();
+                cam.setLocation(new Vector3f(18, 10, 0));
+                cam.lookAt(new Vector3f(0, 0, 0), Vector3f.UNIT_Y);
+
+                AssetManager assetManager = app.getAssetManager();
+                assetManager.registerLoader(KTXLoader.class, "ktx");
+
+                app.getViewPort().setBackgroundColor(ColorRGBA.White);
+
+                modelNode = new Node("modelNode");
+                Geometry model = (Geometry) assetManager.loadModel("Models/Tank/tank.j3o");
+                MikktspaceTangentGenerator.generate(model);
+                modelNode.attachChild(model);
+
+                DirectionalLight dl = new DirectionalLight();
+                dl.setDirection(new Vector3f(-1, -1, -1).normalizeLocal());
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                simpleApp.getRootNode().addLight(dl);
+                dl.setColor(ColorRGBA.White);
+
+                // If we need to update the light direction to match camera
+                if (updateLight) {
+                    dl.setDirection(app.getCamera().getDirection().normalize());
+                }
+
+                simpleApp.getRootNode().attachChild(modelNode);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+                int numSamples = app.getContext().getSettings().getSamples();
+                if (numSamples > 0) {
+                    fpp.setNumSamples(numSamples);
+                }
+
+                fpp.addFilter(new ToneMapFilter(Vector3f.UNIT_XYZ.mult(4.0f)));
+                app.getViewPort().addProcessor(fpp);
+
+                Spatial sky = SkyFactory.createSky(assetManager, "Textures/Sky/Path.hdr", SkyFactory.EnvMapType.EquirectMap);
+                simpleApp.getRootNode().attachChild(sky);
+
+                Material pbrMat = assetManager.loadMaterial("Models/Tank/tank.j3m");
+                pbrMat.setFloat("Roughness", roughness);
+                model.setMaterial(pbrMat);
+
+                // Set up environment camera
+                EnvironmentCamera envCam = new EnvironmentCamera(RESOLUTION, new Vector3f(0, 3f, 0));
+                app.getStateManager().attach(envCam);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+            @Override
+            public void update(float tpf) {
+                frame++;
+
+                if (frame == 2) {
+                    modelNode.removeFromParent();
+                    LightProbe probe;
+
+                    SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                    probe = FastLightProbeFactory.makeProbe(simpleApp.getRenderManager(),
+                                                           simpleApp.getAssetManager(),
+                                                           RESOLUTION,
+                                                           Vector3f.ZERO,
+                                                           1f,
+                                                           1000f,
+                                                           simpleApp.getRootNode());
+
+                    probe.getArea().setRadius(100);
+                    simpleApp.getRootNode().addLight(probe);
+                }
+
+                if (frame > 10 && modelNode.getParent() == null) {
+                    SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                    simpleApp.getRootNode().attachChild(modelNode);
+                }
+            }
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(12)
+          .run();
+    }
+}

+ 138 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSimple.java

@@ -0,0 +1,138 @@
+/*
+ * 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.light.pbr;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.environment.EnvironmentProbeControl;
+import com.jme3.material.Material;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Spatial;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * A simpler PBR example that uses EnvironmentProbeControl to bake the environment
+ *
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+public class TestPBRSimple extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("WithRealtimeBaking", true),
+            Arguments.of("WithoutRealtimeBaking", false)
+        );
+    }
+
+    /**
+     * Test PBR simple with different parameters
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param realtimeBaking Whether to use realtime baking
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRSimple(String testName, boolean realtimeBaking, TestInfo testInfo) {
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private int frame = 0;
+            
+            @Override
+            protected void initialize(Application app) {
+                Camera cam = app.getCamera();
+                cam.setLocation(new Vector3f(18, 10, 0));
+                cam.lookAt(new Vector3f(0, 0, 0), Vector3f.UNIT_Y);
+
+                AssetManager assetManager = app.getAssetManager();
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                
+                // Create the tank model
+                Geometry model = (Geometry) assetManager.loadModel("Models/Tank/tank.j3o");
+                MikktspaceTangentGenerator.generate(model);
+
+                Material pbrMat = assetManager.loadMaterial("Models/Tank/tank.j3m");
+                model.setMaterial(pbrMat);
+                simpleApp.getRootNode().attachChild(model);
+
+                // Create sky
+                Spatial sky = SkyFactory.createSky(assetManager, "Textures/Sky/Path.hdr", SkyFactory.EnvMapType.EquirectMap);
+                simpleApp.getRootNode().attachChild(sky);
+
+                // Create baker control
+                EnvironmentProbeControl envProbe = new EnvironmentProbeControl(assetManager, 256);
+                simpleApp.getRootNode().addControl(envProbe);
+                
+                // Tag the sky, only the tagged spatials will be rendered in the env map
+                envProbe.tag(sky);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+            @Override
+            public void update(float tpf) {
+                if (realtimeBaking) {
+                    frame++;
+                    if (frame == 2) {
+                        SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                        simpleApp.getRootNode().getControl(EnvironmentProbeControl.class).rebake();
+                    }
+                }
+            }
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(10)
+          .run();
+    }
+}

+ 125 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/material/TestSimpleBumps.java

@@ -0,0 +1,125 @@
+/*
+ * 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.material;
+
+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.PointLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.shape.Quad;
+import com.jme3.scene.shape.Sphere;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the SimpleBumps material test.
+ * 
+ * <p>This test creates a quad with a bump map material and a point light that orbits around it.
+ * The light's position is represented by a small red sphere. Screenshots are taken at frames 10 and 60
+ * to capture the light at different positions in its orbit.
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestSimpleBumps extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a bump-mapped quad and an orbiting light.
+     */
+    @Test
+    public void testSimpleBumps() {
+        screenshotTest(new BaseAppState() {
+            private float angle;
+            private PointLight pl;
+            private Spatial lightMdl;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Create quad with bump map material
+                Quad quadMesh = new Quad(1, 1);
+                Geometry sphere = new Geometry("Rock Ball", quadMesh);
+                Material mat = assetManager.loadMaterial("Textures/BumpMapTest/SimpleBump.j3m");
+                sphere.setMaterial(mat);
+                MikktspaceTangentGenerator.generate(sphere);
+                rootNode.attachChild(sphere);
+
+                // Create light representation
+                lightMdl = new Geometry("Light", new Sphere(10, 10, 0.1f));
+                lightMdl.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
+                rootNode.attachChild(lightMdl);
+
+                // Create point light
+                pl = new PointLight();
+                pl.setColor(ColorRGBA.White);
+                pl.setPosition(new Vector3f(0f, 0f, 4f));
+                rootNode.addLight(pl);
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+
+                angle += tpf * 2f;
+                angle %= FastMath.TWO_PI;
+
+                pl.setPosition(new Vector3f(FastMath.cos(angle) * 4f, 0.5f, FastMath.sin(angle) * 4f));
+                lightMdl.setLocalTranslation(pl.getPosition());
+            }
+        })
+        .setFramesToTakeScreenshotsOn(10, 60)
+        .run();
+    }
+}

+ 291 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrain.java

@@ -0,0 +1,291 @@
+/*
+ * 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.terrain;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.TextureKey;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.LightProbe;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.terrain.geomipmap.TerrainLodControl;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Texture;
+import com.jme3.texture.Texture.WrapMode;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * This test uses 'PBRTerrain.j3md' to create a terrain Material for PBR.
+ *
+ * Upon running the app, the user should see a mountainous, terrain-based
+ * landscape with some grassy areas, some snowy areas, and some tiled roads and
+ * gravel paths weaving between the valleys. Snow should be slightly
+ * shiny/reflective, and marble texture should be even shinier. If you would
+ * like to know what each texture is supposed to look like, you can find the
+ * textures used for this test case located in jme3-testdata.
+ *
+ * Uses assets from CC0Textures.com, licensed under CC0 1.0 Universal. For more
+ * information on the textures this test case uses, view the license.txt file
+ * located in the jme3-testdata directory where these textures are located:
+ * jme3-testdata/src/main/resources/Textures/Terrain/PBR
+ *
+ * @author yaRnMcDonuts (Original manual test)
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+@SuppressWarnings("FieldCanBeLocal")
+public class TestPBRTerrain extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("FinalRender", 0),
+            Arguments.of("NormalMap", 1),
+            Arguments.of("RoughnessMap", 2),
+            Arguments.of("MetallicMap", 3),
+            Arguments.of("GeometryNormals", 8)
+        );
+    }
+
+    /**
+     * Test PBR terrain with different debug modes
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param debugMode The debug mode to use
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRTerrain(String testName, int debugMode, TestInfo testInfo) {
+
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private TerrainQuad terrain;
+            private Material matTerrain;
+
+            private final int terrainSize = 512;
+            private final int patchSize = 256;
+            private final float dirtScale = 24;
+            private final float darkRockScale = 24;
+            private final float snowScale = 64;
+            private final float tileRoadScale = 64;
+            private final float grassScale = 24;
+            private final float marbleScale = 64;
+            private final float gravelScale = 64;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                AssetManager assetManager = app.getAssetManager();
+
+                setUpTerrain(simpleApp, assetManager);
+                setUpTerrainMaterial(assetManager);
+                setUpLights(simpleApp, assetManager);
+                setUpCamera(app);
+
+                // Set debug mode
+                matTerrain.setInt("DebugValuesMode", debugMode);
+            }
+
+            private void setUpTerrainMaterial(AssetManager assetManager) {
+                // PBR terrain matdef
+                matTerrain = new Material(assetManager, "Common/MatDefs/Terrain/PBRTerrain.j3md");
+
+                matTerrain.setBoolean("useTriPlanarMapping", false);
+
+                // ALPHA map (for splat textures)
+                matTerrain.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alpha1.png"));
+                matTerrain.setTexture("AlphaMap_1", assetManager.loadTexture("Textures/Terrain/splat/alpha2.png"));
+
+                // DIRT texture
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                dirt.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_0", dirt);
+                matTerrain.setFloat("AlbedoMap_0_scale", dirtScale);
+                matTerrain.setFloat("Roughness_0", 1);
+                matTerrain.setFloat("Metallic_0", 0);
+
+                // DARK ROCK texture
+                Texture darkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Color.png");
+                darkRock.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_1", darkRock);
+                matTerrain.setFloat("AlbedoMap_1_scale", darkRockScale);
+                matTerrain.setFloat("Roughness_1", 0.92f);
+                matTerrain.setFloat("Metallic_1", 0.02f);
+
+                // SNOW texture
+                Texture snow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Color.png");
+                snow.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_2", snow);
+                matTerrain.setFloat("AlbedoMap_2_scale", snowScale);
+                matTerrain.setFloat("Roughness_2", 0.55f);
+                matTerrain.setFloat("Metallic_2", 0.12f);
+
+                // TILES texture
+                Texture tiles = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Color.png");
+                tiles.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_3", tiles);
+                matTerrain.setFloat("AlbedoMap_3_scale", tileRoadScale);
+                matTerrain.setFloat("Roughness_3", 0.87f);
+                matTerrain.setFloat("Metallic_3", 0.08f);
+
+                // GRASS texture
+                Texture grass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                grass.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_4", grass);
+                matTerrain.setFloat("AlbedoMap_4_scale", grassScale);
+                matTerrain.setFloat("Roughness_4", 1);
+                matTerrain.setFloat("Metallic_4", 0);
+
+                // MARBLE texture
+                Texture marble = assetManager.loadTexture("Textures/Terrain/PBR/Marble013_1K_Color.png");
+                marble.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_5", marble);
+                matTerrain.setFloat("AlbedoMap_5_scale", marbleScale);
+                matTerrain.setFloat("Roughness_5", 0.06f);
+                matTerrain.setFloat("Metallic_5", 0.8f);
+
+                // Gravel texture
+                Texture gravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Color.png");
+                gravel.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_6", gravel);
+                matTerrain.setFloat("AlbedoMap_6_scale", gravelScale);
+                matTerrain.setFloat("Roughness_6", 0.9f);
+                matTerrain.setFloat("Metallic_6", 0.07f);
+
+                // NORMAL MAPS
+                Texture normalMapDirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground036_1K_Normal.png");
+                normalMapDirt.setWrap(WrapMode.Repeat);
+
+                Texture normalMapDarkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Normal.png");
+                normalMapDarkRock.setWrap(WrapMode.Repeat);
+
+                Texture normalMapSnow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Normal.png");
+                normalMapSnow.setWrap(WrapMode.Repeat);
+
+                Texture normalMapGravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Normal.png");
+                normalMapGravel.setWrap(WrapMode.Repeat);
+
+                Texture normalMapGrass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Normal.png");
+                normalMapGrass.setWrap(WrapMode.Repeat);
+
+                Texture normalMapTiles = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Normal.png");
+                normalMapTiles.setWrap(WrapMode.Repeat);
+
+                matTerrain.setTexture("NormalMap_0", normalMapDirt);
+                matTerrain.setTexture("NormalMap_1", normalMapDarkRock);
+                matTerrain.setTexture("NormalMap_2", normalMapSnow);
+                matTerrain.setTexture("NormalMap_3", normalMapTiles);
+                matTerrain.setTexture("NormalMap_4", normalMapGrass);
+                matTerrain.setTexture("NormalMap_6", normalMapGravel);
+
+                terrain.setMaterial(matTerrain);
+            }
+
+            private void setUpTerrain(SimpleApplication simpleApp, AssetManager assetManager) {
+                // HEIGHTMAP image (for the terrain heightmap)
+                TextureKey hmKey = new TextureKey("Textures/Terrain/splat/mountains512.png", false);
+                Texture heightMapImage = assetManager.loadTexture(hmKey);
+
+                // CREATE HEIGHTMAP
+                AbstractHeightMap heightmap;
+                try {
+                    heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.3f);
+                    heightmap.load();
+                    heightmap.smooth(0.9f, 1);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+
+                terrain = new TerrainQuad("terrain", patchSize + 1, terrainSize + 1, heightmap.getHeightMap());
+                TerrainLodControl control = new TerrainLodControl(terrain, getApplication().getCamera());
+                control.setLodCalculator(new DistanceLodCalculator(patchSize + 1, 2.7f)); // patch size, and a multiplier
+                terrain.addControl(control);
+                terrain.setMaterial(matTerrain);
+                terrain.setLocalTranslation(0, -100, 0);
+                terrain.setLocalScale(1f, 1f, 1f);
+                simpleApp.getRootNode().attachChild(terrain);
+            }
+
+            private void setUpLights(SimpleApplication simpleApp, AssetManager assetManager) {
+                LightProbe probe = (LightProbe) assetManager.loadAsset("Scenes/LightProbes/quarry_Probe.j3o");
+
+                probe.setAreaType(LightProbe.AreaType.Spherical);
+                probe.getArea().setRadius(2000);
+                probe.getArea().setCenter(new Vector3f(0, 0, 0));
+                simpleApp.getRootNode().addLight(probe);
+
+                DirectionalLight directionalLight = new DirectionalLight();
+                directionalLight.setDirection((new Vector3f(-0.3f, -0.5f, -0.3f)).normalize());
+                directionalLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(directionalLight);
+
+                AmbientLight ambientLight = new AmbientLight();
+                ambientLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(ambientLight);
+            }
+
+            private void setUpCamera(Application app) {
+                app.getCamera().setLocation(new Vector3f(0, 10, -10));
+                app.getCamera().lookAtDirection(new Vector3f(0, -1.5f, -1).normalizeLocal(), Vector3f.UNIT_Y);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(5)
+          .run();
+    }
+}

+ 368 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrainAdvanced.java

@@ -0,0 +1,368 @@
+/*
+ * 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.terrain;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.TextureKey;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.LightProbe;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.shader.VarType;
+import com.jme3.terrain.geomipmap.TerrainLodControl;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Image;
+import com.jme3.texture.Texture;
+import com.jme3.texture.Texture.WrapMode;
+import com.jme3.texture.Texture.MagFilter;
+import com.jme3.texture.Texture.MinFilter;
+import com.jme3.texture.TextureArray;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+
+/**
+ * This test uses 'AdvancedPBRTerrain.j3md' to create a terrain Material with
+ * more textures than 'PBRTerrain.j3md' can handle.
+ *
+ * Upon running the app, the user should see a mountainous, terrain-based
+ * landscape with some grassy areas, some snowy areas, and some tiled roads and
+ * gravel paths weaving between the valleys. Snow should be slightly
+ * shiny/reflective, and marble texture should be even shinier. If you would
+ * like to know what each texture is supposed to look like, you can find the
+ * textures used for this test case located in jme3-testdata.
+
+ * The MetallicRoughness map stores:
+ * <ul>
+ * <li> AmbientOcclusion in the Red channel </li>
+ * <li> Roughness in the Green channel </li>
+ * <li> Metallic in the Blue channel </li>
+ * <li> EmissiveIntensity in the Alpha channel </li>
+ * </ul>
+ *
+ * The shaders are still subject to the GLSL max limit of 16 textures, however
+ * each TextureArray counts as a single texture, and each TextureArray can store
+ * multiple images. For more information on texture arrays see:
+ * https://www.khronos.org/opengl/wiki/Array_Texture
+ *
+ * Uses assets from CC0Textures.com, licensed under CC0 1.0 Universal. For more
+ * information on the textures this test case uses, view the license.txt file
+ * located in the jme3-testdata directory where these textures are located:
+ * jme3-testdata/src/main/resources/Textures/Terrain/PBR
+ *
+ * @author yaRnMcDonuts - original test
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+@SuppressWarnings("FieldCanBeLocal")
+public class TestPBRTerrainAdvanced extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("FinalRender", 0),
+            Arguments.of("AmbientOcclusion", 4),
+            Arguments.of("Emissive", 5)
+        );
+    }
+
+    /**
+     * Test advanced PBR terrain with different debug modes
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param debugMode The debug mode to use
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRTerrainAdvanced(String testName, int debugMode, TestInfo testInfo) {
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private TerrainQuad terrain;
+            private Material matTerrain;
+            
+            private final int terrainSize = 512;
+            private final int patchSize = 256;
+            private final float dirtScale = 24;
+            private final float darkRockScale = 24;
+            private final float snowScale = 64;
+            private final float tileRoadScale = 64;
+            private final float grassScale = 24;
+            private final float marbleScale = 64;
+            private final float gravelScale = 64;
+            
+            private final ColorRGBA tilesEmissiveColor = new ColorRGBA(0.12f, 0.02f, 0.23f, 0.85f); //dim magenta emission
+            private final ColorRGBA marbleEmissiveColor = new ColorRGBA(0.0f, 0.0f, 1.0f, 1.0f); //fully saturated blue emission
+            
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                AssetManager assetManager = app.getAssetManager();
+                
+                setUpTerrain(simpleApp, assetManager);
+                setUpTerrainMaterial(assetManager);
+                setUpLights(simpleApp, assetManager);
+                setUpCamera(app);
+                
+                // Set debug mode
+                matTerrain.setInt("DebugValuesMode", debugMode);
+            }
+
+            private void setUpTerrainMaterial(AssetManager assetManager) {
+                // advanced PBR terrain matdef
+                matTerrain = new Material(assetManager, "Common/MatDefs/Terrain/AdvancedPBRTerrain.j3md");
+
+                matTerrain.setBoolean("useTriPlanarMapping", false);
+
+                // ALPHA map (for splat textures)
+                matTerrain.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alpha1.png"));
+                matTerrain.setTexture("AlphaMap_1", assetManager.loadTexture("Textures/Terrain/splat/alpha2.png"));
+
+                // load textures for texture arrays
+                // These MUST all have the same dimensions and format in order to be put into a texture array.
+                //ALBEDO MAPS
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                Texture darkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Color.png");
+                Texture snow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Color.png");
+                Texture tileRoad = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Color.png");
+                Texture grass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                Texture marble = assetManager.loadTexture("Textures/Terrain/PBR/Marble013_1K_Color.png");
+                Texture gravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Color.png");
+
+                // NORMAL MAPS
+                Texture normalMapDirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground036_1K_Normal.png");
+                Texture normalMapDarkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Normal.png");
+                Texture normalMapSnow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Normal.png");
+                Texture normalMapGravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Normal.png");
+                Texture normalMapGrass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Normal.png");
+                Texture normalMapMarble = assetManager.loadTexture("Textures/Terrain/PBR/Marble013_1K_Normal.png");
+                Texture normalMapRoad = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Normal.png");
+
+                //PACKED METALLIC/ROUGHNESS / AMBIENT OCCLUSION / EMISSIVE INTENSITY MAPS
+                Texture metallicRoughnessAoEiMapDirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground036_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapDarkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapSnow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapGravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel_015_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapGrass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapMarble = assetManager.loadTexture("Textures/Terrain/PBR/Marble013_PackedMetallicRoughnessMap.png");
+                Texture metallicRoughnessAoEiMapRoad = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_PackedMetallicRoughnessMap.png");
+
+                // put all images into lists to create texture arrays.
+                List<Image> albedoImages = new ArrayList<>();
+                List<Image> normalMapImages = new ArrayList<>();
+                List<Image> metallicRoughnessAoEiMapImages = new ArrayList<>();
+
+                albedoImages.add(dirt.getImage());  //0
+                albedoImages.add(darkRock.getImage()); //1
+                albedoImages.add(snow.getImage()); //2
+                albedoImages.add(tileRoad.getImage()); //3
+                albedoImages.add(grass.getImage()); //4
+                albedoImages.add(marble.getImage()); //5
+                albedoImages.add(gravel.getImage()); //6
+
+                normalMapImages.add(normalMapDirt.getImage());  //0
+                normalMapImages.add(normalMapDarkRock.getImage());  //1
+                normalMapImages.add(normalMapSnow.getImage());  //2
+                normalMapImages.add(normalMapRoad.getImage());   //3
+                normalMapImages.add(normalMapGrass.getImage());   //4
+                normalMapImages.add(normalMapMarble.getImage());   //5
+                normalMapImages.add(normalMapGravel.getImage());   //6
+
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapDirt.getImage());  //0
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapDarkRock.getImage());  //1
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapSnow.getImage());  //2
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapRoad.getImage());   //3
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapGrass.getImage());   //4
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapMarble.getImage());   //5
+                metallicRoughnessAoEiMapImages.add(metallicRoughnessAoEiMapGravel.getImage());   //6
+
+                //initiate texture arrays
+                TextureArray albedoTextureArray = new TextureArray(albedoImages);
+                TextureArray normalParallaxTextureArray = new TextureArray(normalMapImages); // parallax is not used currently
+                TextureArray metallicRoughnessAoEiTextureArray = new TextureArray(metallicRoughnessAoEiMapImages);
+
+                //apply wrapMode to the whole texture array, rather than each individual texture in the array
+                setWrapAndMipMaps(albedoTextureArray);
+                setWrapAndMipMaps(normalParallaxTextureArray);
+                setWrapAndMipMaps(metallicRoughnessAoEiTextureArray);
+                
+                //assign texture array to materials
+                matTerrain.setParam("AlbedoTextureArray", VarType.TextureArray, albedoTextureArray);
+                matTerrain.setParam("NormalParallaxTextureArray", VarType.TextureArray, normalParallaxTextureArray);
+                matTerrain.setParam("MetallicRoughnessAoEiTextureArray", VarType.TextureArray, metallicRoughnessAoEiTextureArray);
+
+                //set up texture slots:
+                matTerrain.setInt("AlbedoMap_0", 0); // dirt is index 0 in the albedo image list
+                matTerrain.setFloat("AlbedoMap_0_scale", dirtScale);
+                matTerrain.setFloat("Roughness_0", 1);
+                matTerrain.setFloat("Metallic_0", 0.02f);
+
+                matTerrain.setInt("AlbedoMap_1", 1);   // darkRock is index 1 in the albedo image list
+                matTerrain.setFloat("AlbedoMap_1_scale", darkRockScale);
+                matTerrain.setFloat("Roughness_1", 1);
+                matTerrain.setFloat("Metallic_1", 0.04f);
+
+                matTerrain.setInt("AlbedoMap_2", 2);
+                matTerrain.setFloat("AlbedoMap_2_scale", snowScale);
+                matTerrain.setFloat("Roughness_2", 0.72f);
+                matTerrain.setFloat("Metallic_2", 0.12f);
+
+                matTerrain.setInt("AlbedoMap_3", 3);
+                matTerrain.setFloat("AlbedoMap_3_scale", tileRoadScale);
+                matTerrain.setFloat("Roughness_3", 1);
+                matTerrain.setFloat("Metallic_3", 0.04f);
+
+                matTerrain.setInt("AlbedoMap_4", 4);
+                matTerrain.setFloat("AlbedoMap_4_scale", grassScale);
+                matTerrain.setFloat("Roughness_4", 1);
+                matTerrain.setFloat("Metallic_4", 0);
+
+                matTerrain.setInt("AlbedoMap_5", 5);
+                matTerrain.setFloat("AlbedoMap_5_scale", marbleScale);
+                matTerrain.setFloat("Roughness_5", 1);
+                matTerrain.setFloat("Metallic_5", 0.2f);
+
+                matTerrain.setInt("AlbedoMap_6", 6);
+                matTerrain.setFloat("AlbedoMap_6_scale", gravelScale);
+                matTerrain.setFloat("Roughness_6", 1);
+                matTerrain.setFloat("Metallic_6", 0.01f);
+
+                // NORMAL MAPS
+                matTerrain.setInt("NormalMap_0", 0);
+                matTerrain.setInt("NormalMap_1", 1);
+                matTerrain.setInt("NormalMap_2", 2);
+                matTerrain.setInt("NormalMap_3", 3);
+                matTerrain.setInt("NormalMap_4", 4);
+                matTerrain.setInt("NormalMap_5", 5);
+                matTerrain.setInt("NormalMap_6", 6);
+
+                //METALLIC/ROUGHNESS/AO/EI MAPS
+                matTerrain.setInt("MetallicRoughnessMap_0", 0);
+                matTerrain.setInt("MetallicRoughnessMap_1", 1);
+                matTerrain.setInt("MetallicRoughnessMap_2", 2);
+                matTerrain.setInt("MetallicRoughnessMap_3", 3);
+                matTerrain.setInt("MetallicRoughnessMap_4", 4);
+                matTerrain.setInt("MetallicRoughnessMap_5", 5);
+                matTerrain.setInt("MetallicRoughnessMap_6", 6);
+
+                //EMISSIVE
+                matTerrain.setColor("EmissiveColor_5", marbleEmissiveColor);
+                matTerrain.setColor("EmissiveColor_3", tilesEmissiveColor);
+
+                terrain.setMaterial(matTerrain);
+            }
+
+            private void setWrapAndMipMaps(Texture texture) {
+                texture.setWrap(WrapMode.Repeat);
+                texture.setMinFilter(MinFilter.Trilinear);
+                texture.setMagFilter(MagFilter.Bilinear);
+            }
+
+            private void setUpTerrain(SimpleApplication simpleApp, AssetManager assetManager) {
+                // HEIGHTMAP image (for the terrain heightmap)
+                TextureKey hmKey = new TextureKey("Textures/Terrain/splat/mountains512.png", false);
+                Texture heightMapImage = assetManager.loadTexture(hmKey);
+
+                // CREATE HEIGHTMAP
+                AbstractHeightMap heightmap;
+                try {
+                    heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.3f);
+                    heightmap.load();
+                    heightmap.smooth(0.9f, 1);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+
+                terrain = new TerrainQuad("terrain", patchSize + 1, terrainSize + 1, heightmap.getHeightMap());
+                TerrainLodControl control = new TerrainLodControl(terrain, getApplication().getCamera());
+                control.setLodCalculator(new DistanceLodCalculator(patchSize + 1, 2.7f)); // patch size, and a multiplier
+                terrain.addControl(control);
+                terrain.setMaterial(matTerrain);
+                terrain.setLocalTranslation(0, -100, 0);
+                terrain.setLocalScale(1f, 1f, 1f);
+                simpleApp.getRootNode().attachChild(terrain);
+            }
+
+            private void setUpLights(SimpleApplication simpleApp, AssetManager assetManager) {
+                LightProbe probe = (LightProbe) assetManager.loadAsset("Scenes/LightProbes/quarry_Probe.j3o");
+
+                probe.setAreaType(LightProbe.AreaType.Spherical);
+                probe.getArea().setRadius(2000);
+                probe.getArea().setCenter(new Vector3f(0, 0, 0));
+                simpleApp.getRootNode().addLight(probe);
+
+                DirectionalLight directionalLight = new DirectionalLight();
+                directionalLight.setDirection((new Vector3f(-0.3f, -0.5f, -0.3f)).normalize());
+                directionalLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(directionalLight);
+
+                AmbientLight ambientLight = new AmbientLight();
+                ambientLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(ambientLight);
+            }
+
+            private void setUpCamera(Application app) {
+                app.getCamera().setLocation(new Vector3f(0, 10, -10));
+                app.getCamera().lookAtDirection(new Vector3f(0, -1.5f, -1).normalizeLocal(), Vector3f.UNIT_Y);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(5)
+          .run();
+    }
+}

BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestIssue2076.testIssue2076_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestMotionPath.testMotionPath_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestMotionPath.testMotionPath_f60.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_DefaultDirectionalLight_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_HighRoughness_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_LowRoughness_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_UpdatedDirectionalLight_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithRealtimeBaking_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithoutRealtimeBaking_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.material.TestSimpleBumps.testSimpleBumps_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.material.TestSimpleBumps.testSimpleBumps_f60.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_FinalRender_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_GeometryNormals_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_MetallicMap_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_NormalMap_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrain.testPBRTerrain_RoughnessMap_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_AmbientOcclusion_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_Emissive_f5.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.terrain.TestPBRTerrainAdvanced.testPBRTerrainAdvanced_FinalRender_f5.png