Selaa lähdekoodia

Merge branch 'master' into capdevon-AppSettings

Ryan McDonough 1 kuukausi sitten
vanhempi
commit
8b41d079ba
60 muutettua tiedostoa jossa 3878 lisäystä ja 1274 poistoa
  1. 68 0
      .github/actions/tools/uploadToCentral.sh
  2. 24 21
      .github/workflows/main.yml
  3. 2 0
      .github/workflows/screenshot-test-comment.yml
  4. 20 12
      common.gradle
  5. 54 7
      jme3-core/src/main/java/com/jme3/anim/MatrixJointModelTransform.java
  6. 181 150
      jme3-core/src/main/java/com/jme3/anim/SkinningControl.java
  7. 2 3
      jme3-core/src/main/java/com/jme3/animation/LoopMode.java
  8. 75 25
      jme3-core/src/main/java/com/jme3/app/state/ConstantVerifierState.java
  9. 5 1
      jme3-core/src/main/java/com/jme3/audio/AudioNode.java
  10. 17 19
      jme3-core/src/main/java/com/jme3/cinematic/events/MotionEvent.java
  11. 1 1
      jme3-core/src/main/java/com/jme3/effect/ParticleEmitter.java
  12. 92 39
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterMeshFaceShape.java
  13. 1 0
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterSphereShape.java
  14. 167 0
      jme3-core/src/main/java/com/jme3/environment/util/Circle.java
  15. 359 86
      jme3-core/src/main/java/com/jme3/environment/util/LightsDebugState.java
  16. 136 123
      jme3-core/src/main/java/com/jme3/input/FlyByCamera.java
  17. 5 1
      jme3-core/src/main/java/com/jme3/input/JoystickButton.java
  18. 10 1
      jme3-core/src/main/java/com/jme3/light/AmbientLight.java
  19. 11 6
      jme3-core/src/main/java/com/jme3/light/DirectionalLight.java
  20. 13 13
      jme3-core/src/main/java/com/jme3/light/LightProbe.java
  21. 12 1
      jme3-core/src/main/java/com/jme3/light/PointLight.java
  22. 16 5
      jme3-core/src/main/java/com/jme3/light/SpotLight.java
  23. 85 63
      jme3-core/src/main/java/com/jme3/material/Material.java
  24. 9 5
      jme3-core/src/main/java/com/jme3/material/Materials.java
  25. 109 104
      jme3-core/src/main/java/com/jme3/math/ColorRGBA.java
  26. 1 1
      jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java
  27. 3 1
      jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java
  28. 22 5
      jme3-core/src/main/java/com/jme3/scene/VertexBuffer.java
  29. 157 60
      jme3-core/src/main/java/com/jme3/scene/control/LightControl.java
  30. 113 27
      jme3-core/src/main/java/com/jme3/scene/debug/WireFrustum.java
  31. 340 112
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java
  32. 136 50
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugger.java
  33. 26 10
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureInterJointsWire.java
  34. 215 57
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureNode.java
  35. 110 13
      jme3-core/src/main/java/com/jme3/scene/mesh/MorphTarget.java
  36. 52 6
      jme3-core/src/main/java/com/jme3/shader/VarType.java
  37. 18 30
      jme3-core/src/main/java/com/jme3/shadow/AbstractShadowFilter.java
  38. 23 0
      jme3-core/src/main/java/com/jme3/system/AppSettings.java
  39. 38 0
      jme3-core/src/main/resources/Common/MatDefs/Misc/Dashed.j3md
  40. 23 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn
  41. 9 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag
  42. BIN
      jme3-core/src/main/resources/Common/Textures/lightbulb32.png
  43. 153 72
      jme3-core/src/plugins/java/com/jme3/audio/plugins/WAVLoader.java
  44. 7 9
      jme3-core/src/plugins/java/com/jme3/material/plugins/ShaderNodeDefinitionLoader.java
  45. 30 0
      jme3-core/src/test/java/com/jme3/math/ColorRGBATest.java
  46. 79 69
      jme3-effects/src/main/java/com/jme3/post/ssao/SSAOFilter.java
  47. 63 0
      jme3-effects/src/test/java/com/jme3/post/filters/SSAOFilterTest.java
  48. 79 23
      jme3-examples/src/main/java/jme3test/export/TestOgreConvert.java
  49. 85 43
      jme3-examples/src/main/java/jme3test/stress/TestLodGeneration.java
  50. 23 0
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java
  51. 151 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/model/shape/TestBillboard.java
  52. 150 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestCartoonEdge.java
  53. 172 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestFog.java
  54. 126 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestLightScattering.java
  55. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromAbove_f1.png
  56. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromFront_f1.png
  57. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromRight_f1.png
  58. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestCartoonEdge.testCartoonEdge_f1.png
  59. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestFog.testFog_f1.png
  60. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestLightScattering.testLightScattering_f1.png

+ 68 - 0
.github/actions/tools/uploadToCentral.sh

@@ -0,0 +1,68 @@
+#! /bin/bash
+set -euo pipefail
+
+## Upload a deployment
+## from the "org.jmonkeyengine" namespace in Sonatype's OSSRH staging area
+## to Sonatype's Central Publisher Portal
+## so the deployment can be tested and then published or dropped.
+
+## IMPORTANT:  The upload request must originate
+## from the IP address used to stage the deployment to the staging area!
+
+# The required -p and -u flags on the command line
+# specify the password and username components of a "user token"
+# generated using the web interface at https://central.sonatype.com/account
+
+while getopts p:u: flag
+do
+    case "${flag}" in
+        p) centralPassword=${OPTARG};;
+        u) centralUsername=${OPTARG};;
+    esac
+done
+
+# Combine both components into a base64 "user token"
+# suitable for the Authorization header of a POST request:
+
+token=$(printf %s:%s "${centralUsername}" "${centralPassword}" | base64)
+
+# Send a POST request to upload the deployment:
+
+server='ossrh-staging-api.central.sonatype.com'
+endpoint='/manual/upload/defaultRepository/org.jmonkeyengine'
+url="https://${server}${endpoint}"
+
+statusCode=$(curl "${url}" \
+  --no-progress-meter \
+  --output postData1.txt \
+  --write-out '%{response_code}' \
+  --request POST \
+  --header 'accept: */*' \
+  --header "Authorization: Bearer ${token}" \
+  --data '')
+
+echo "Status code = ${statusCode}"
+echo 'Received data:'
+cat postData1.txt
+echo '[EOF]'
+
+# Retry if the default repo isn't found (status=400).
+
+if [ "${statusCode}" == "400" ]; then
+  echo "Will retry after 30 seconds."
+  sleep 30
+
+  statusCode2=$(curl "${url}" \
+    --no-progress-meter \
+    --output postData2.txt \
+    --write-out '%{response_code}' \
+    --request POST \
+    --header 'accept: */*' \
+    --header "Authorization: Bearer ${token}" \
+    --data '')
+
+  echo "Status code = ${statusCode2}"
+  echo 'Received data:'
+  cat postData2.txt
+  echo '[EOF]'
+fi

+ 24 - 21
.github/workflows/main.yml

@@ -16,8 +16,8 @@
 # >> Configure MINIO NATIVES SNAPSHOT
 #     OBJECTS_KEY=XXXXXX
 # >> Configure SONATYPE RELEASE
-#     OSSRH_PASSWORD=XXXXXX
-#     OSSRH_USERNAME=XXXXXX
+#     CENTRAL_PASSWORD=XXXXXX
+#     CENTRAL_USERNAME=XXXXXX
 # >> Configure SIGNING
 #     SIGNING_KEY=XXXXXX
 #     SIGNING_PASSWORD=XXXXXX
@@ -359,16 +359,16 @@ jobs:
           name: android-natives
           path: build/native
 
-      - name: Rebuild the maven artifacts and deploy them to the Sonatype repository
+      - name: Rebuild the maven artifacts and upload them to Sonatype's maven-snapshots repo
         run: |
-          if [ "${{ secrets.OSSRH_PASSWORD }}" = "" ];
+          if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ];
           then
-            echo "Configure the following secrets to enable deployment to Sonatype:"
-            echo "OSSRH_PASSWORD, OSSRH_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
+            echo "Configure the following secrets to enable uploading to Sonatype:"
+            echo "CENTRAL_PASSWORD, CENTRAL_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
           else
             ./gradlew publishMavenPublicationToSNAPSHOTRepository \
-            -PossrhPassword=${{ secrets.OSSRH_PASSWORD }} \
-            -PossrhUsername=${{ secrets.OSSRH_USERNAME }} \
+            -PcentralPassword=${{ secrets.CENTRAL_PASSWORD }} \
+            -PcentralUsername=${{ secrets.CENTRAL_USERNAME }} \
             -PsigningKey='${{ secrets.SIGNING_KEY }}' \
             -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
             -PuseCommitHashAsVersionName=true \
@@ -384,13 +384,13 @@ jobs:
     if: github.event_name == 'release'
     steps:
 
-      # We need to clone everything again for uploadToMaven.sh ...
+      # We need to clone everything again for uploadToCentral.sh ...
       - name: Clone the repo
         uses: actions/checkout@v4
         with:
           fetch-depth: 1
 
-      # Setup jdk 21 used for building Sonatype OSSRH artifacts
+      # Setup jdk 21 used for building Sonatype artifacts
       - name: Setup the java environment
         uses: actions/setup-java@v4
         with:
@@ -416,20 +416,23 @@ jobs:
           name: android-natives
           path: build/native
 
-      - name: Rebuild the maven artifacts and deploy them to Sonatype OSSRH
+      - name: Rebuild the maven artifacts and upload them to Sonatype's Central Publisher Portal
         run: |
-          if [ "${{ secrets.OSSRH_PASSWORD }}" = "" ];
+          if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ];
           then
-            echo "Configure the following secrets to enable deployment to Sonatype:"
-            echo "OSSRH_PASSWORD, OSSRH_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
+            echo "Configure the following secrets to enable uploading to Sonatype:"
+            echo "CENTRAL_PASSWORD, CENTRAL_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
           else
-            ./gradlew publishMavenPublicationToOSSRHRepository \
-            -PossrhPassword=${{ secrets.OSSRH_PASSWORD }} \
-            -PossrhUsername=${{ secrets.OSSRH_USERNAME }} \
-            -PsigningKey='${{ secrets.SIGNING_KEY }}' \
-            -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
-            -PuseCommitHashAsVersionName=true \
-            --console=plain --stacktrace
+            ./gradlew publishMavenPublicationToCentralRepository \
+              -PcentralPassword=${{ secrets.CENTRAL_PASSWORD }} \
+              -PcentralUsername=${{ secrets.CENTRAL_USERNAME }} \
+              -PsigningKey='${{ secrets.SIGNING_KEY }}' \
+              -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
+              -PuseCommitHashAsVersionName=true \
+              --console=plain --stacktrace
+            .github/actions/tools/uploadToCentral.sh \
+              -p '${{ secrets.CENTRAL_PASSWORD }}' \
+              -u '${{ secrets.CENTRAL_USERNAME }}'
           fi
 
       - name: Deploy to GitHub Releases

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

@@ -113,5 +113,7 @@ jobs:
             **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
+            
+            Contact @richardTingle (aka richtea) for guidance if required
           edit-mode: replace
           comment-id: ${{ steps.existingCommentId.outputs.comment-id }}

+ 20 - 12
common.gradle

@@ -157,27 +157,35 @@ publishing {
             version project.version
         }
     }
+
     repositories {
         maven {
             name = 'Dist'
             url = gradle.rootProject.projectDir.absolutePath + '/dist/maven'
         }
+
+        // Uploading to Sonatype relies on the existence of 2 properties
+        // (centralUsername and centralPassword)
+        // which should be set using -P options on the command line.
+
+        maven {
+            // for uploading release builds to the default repo in Sonatype's OSSRH staging area
+            credentials {
+                username = gradle.rootProject.hasProperty('centralUsername') ? centralUsername : 'Unknown user'
+                password = gradle.rootProject.hasProperty('centralPassword') ? centralPassword : 'Unknown password'
+            }
+            name = 'Central'
+            url = 'https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/'
+        }
         maven {
+            // for uploading snapshot builds to Sonatype's maven-snapshots repo
             credentials {
-                username = gradle.rootProject.hasProperty('ossrhUsername') ? ossrhUsername : 'Unknown user'
-                password = gradle.rootProject.hasProperty('ossrhPassword') ? ossrhPassword : 'Unknown password'
+                username = gradle.rootProject.hasProperty('centralUsername') ? centralUsername : 'Unknown user'
+                password = gradle.rootProject.hasProperty('centralPassword') ? centralPassword : 'Unknown password'
             }
-            name = 'OSSRH'
-            url = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2'
+            name = 'SNAPSHOT'
+            url = 'https://central.sonatype.com/repository/maven-snapshots/'
         }
-	maven {
-	    credentials {
-                username = gradle.rootProject.hasProperty('ossrhUsername') ? ossrhUsername : 'Unknown user'
-                password = gradle.rootProject.hasProperty('ossrhPassword') ? ossrhPassword : 'Unknown password'
-	    }
-	    name = 'SNAPSHOT'
-	    url = 'https://s01.oss.sonatype.org/content/repositories/snapshots/'
-	}
     }
 }
 

+ 54 - 7
jme3-core/src/main/java/com/jme3/anim/MatrixJointModelTransform.java

@@ -1,3 +1,34 @@
+/*
+ * Copyright (c) 2009-2025 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 com.jme3.anim;
 
 import com.jme3.anim.util.JointModelTransform;
@@ -5,21 +36,36 @@ import com.jme3.math.Matrix4f;
 import com.jme3.math.Transform;
 
 /**
- * This JointModelTransform implementation accumulate joints transforms in a Matrix4f to properly
- * support non uniform scaling in an armature hierarchy
+ * An implementation of {@link JointModelTransform} that accumulates joint transformations
+ * into a {@link Matrix4f}. This approach is particularly useful for correctly handling
+ * non-uniform scaling within an armature hierarchy, as {@code Matrix4f} can represent
+ * non-uniform scaling directly, unlike {@link Transform}, which typically handles
+ * uniform scaling.
+ * <p>
+ * This class maintains a single {@link Matrix4f} to represent the accumulated
+ * model-space transform of the joint it's associated with.
  */
 public class MatrixJointModelTransform implements JointModelTransform {
 
-    final private Matrix4f modelTransformMatrix = new Matrix4f();
-    final private Transform modelTransform = new Transform();
+    /**
+     * The model-space transform of the joint represented as a Matrix4f.
+     * This matrix accumulates the local transform of the joint and the model transform
+     * of its parent.
+     */
+    private final Matrix4f modelTransformMatrix = new Matrix4f();
+    /**
+     * A temporary Transform instance used for converting the modelTransformMatrix
+     * to a Transform object when {@link #getModelTransform()} is called.
+     */
+    private final Transform modelTransform = new Transform();
 
     @Override
     public void updateModelTransform(Transform localTransform, Joint parent) {
         localTransform.toTransformMatrix(modelTransformMatrix);
         if (parent != null) {
-            ((MatrixJointModelTransform) parent.getJointModelTransform()).getModelTransformMatrix().mult(modelTransformMatrix, modelTransformMatrix);
+            MatrixJointModelTransform transform = (MatrixJointModelTransform) parent.getJointModelTransform();
+            transform.getModelTransformMatrix().mult(modelTransformMatrix, modelTransformMatrix);
         }
-
     }
 
     @Override
@@ -31,7 +77,8 @@ public class MatrixJointModelTransform implements JointModelTransform {
     public void applyBindPose(Transform localTransform, Matrix4f inverseModelBindMatrix, Joint parent) {
         modelTransformMatrix.set(inverseModelBindMatrix).invertLocal(); // model transform = model bind
         if (parent != null) {
-            ((MatrixJointModelTransform) parent.getJointModelTransform()).getModelTransformMatrix().invert().mult(modelTransformMatrix, modelTransformMatrix);
+            MatrixJointModelTransform transform = (MatrixJointModelTransform) parent.getJointModelTransform();
+            transform.getModelTransformMatrix().invert().mult(modelTransformMatrix, modelTransformMatrix);
         }
         localTransform.fromTransformMatrix(modelTransformMatrix);
     }

+ 181 - 150
jme3-core/src/main/java/com/jme3/anim/SkinningControl.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
@@ -31,12 +31,21 @@
  */
 package com.jme3.anim;
 
-import com.jme3.export.*;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
 import com.jme3.material.MatParamOverride;
 import com.jme3.math.FastMath;
 import com.jme3.math.Matrix4f;
-import com.jme3.renderer.*;
-import com.jme3.scene.*;
+import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.RendererException;
+import com.jme3.renderer.ViewPort;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.control.AbstractControl;
 import com.jme3.scene.mesh.IndexBuffer;
@@ -53,64 +62,77 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
- * The Skinning control deforms a model according to an armature, It handles the
- * computation of the deformation matrices and performs the transformations on
- * the mesh
+ * The `SkinningControl` deforms a 3D model according to an {@link Armature}. It manages the
+ * computation of deformation matrices and applies these transformations to the mesh,
+ * supporting both software and hardware-accelerated skinning.
+ *
+ * <p>
+ * **Software Skinning:** Performed on the CPU, offering broader compatibility but
+ * potentially lower performance for complex models.
  * <p>
- * It can perform software skinning or Hardware skinning
+ * **Hardware Skinning:** Utilizes the GPU for deformation, providing significantly
+ * better performance but requiring shader support and having a limit on the number
+ * of bones (typically 255 in common shaders).
  *
- * @author Rémy Bouquet Based on SkeletonControl by Kirill Vainer
+ * @author Nehon
  */
-public class SkinningControl extends AbstractControl implements Cloneable, JmeCloneable {
+public class SkinningControl extends AbstractControl implements JmeCloneable {
 
     private static final Logger logger = Logger.getLogger(SkinningControl.class.getName());
 
+    /**
+     * The maximum number of bones supported for hardware skinning in common shaders.
+     */
+    private static final int MAX_BONES_HW_SKINNING_SUPPORT = 255;
+
     /**
      * The armature of the model.
      */
     private Armature armature;
 
     /**
-     * List of geometries affected by this control.
+     * A list of geometries that this control will deform.
      */
     private SafeArrayList<Geometry> targets = new SafeArrayList<>(Geometry.class);
 
     /**
-     * Used to track when a mesh was updated. Meshes are only updated if they
+     * Used to track when a mesh needs to be updated. Meshes are only updated if they
      * are visible in at least one camera.
      */
-    private boolean wasMeshUpdated = false;
+    private boolean meshUpdateRequired = true;
 
     /**
-     * User wishes to use hardware skinning if available.
+     * Indicates whether hardware skinning is preferred. If `true` and the GPU
+     * supports it, hardware skinning will be enabled.
      */
-    private transient boolean hwSkinningDesired = true;
+    private transient boolean hwSkinningPreferred = true;
 
     /**
-     * Hardware skinning is currently being used.
+     * Indicates if hardware skinning is currently active and being used.
      */
     private transient boolean hwSkinningEnabled = false;
 
     /**
-     * Hardware skinning was tested on this GPU, results
-     * are stored in {@link #hwSkinningSupported} variable.
+     * Flag indicating whether hardware skinning compatibility has been tested
+     * on the current GPU. Results are stored in {@link #hwSkinningSupported}.
      */
     private transient boolean hwSkinningTested = false;
 
     /**
-     * If hardware skinning was {@link #hwSkinningTested tested}, then
-     * this variable will be set to true if supported, and false if otherwise.
+     * Stores the result of the hardware skinning compatibility test. `true` if
+     * supported, `false` otherwise. This is only valid after
+     * {@link #hwSkinningTested} is `true`.
      */
     private transient boolean hwSkinningSupported = false;
 
     /**
-     * Bone offset matrices, recreated each frame
+     * Bone offset matrices, computed each frame to deform the mesh based on
+     * the armature's current pose.
      */
-    private transient Matrix4f[] offsetMatrices;
-
+    private transient Matrix4f[] boneOffsetMatrices;
 
-    private MatParamOverride numberOfJointsParam;
-    private MatParamOverride jointMatricesParam;
+    private MatParamOverride numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
+    private MatParamOverride jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
 
     /**
      * Serialization only. Do not use.
@@ -119,26 +141,26 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Creates an armature control. The list of targets will be acquired
-     * automatically when the control is attached to a node.
+     * Creates a new `SkinningControl` for the given armature.
      *
-     * @param armature the armature
+     * @param armature The armature that drives the deformation (not null).
      */
     public SkinningControl(Armature armature) {
         if (armature == null) {
-            throw new IllegalArgumentException("armature cannot be null");
+            throw new IllegalArgumentException("armature cannot be null.");
         }
         this.armature = armature;
-        this.numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
-        this.jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
     }
 
-
-    private void switchToHardware() {
+    /**
+     * Configures the material parameters and meshes for hardware skinning.
+     */
+    private void enableHardwareSkinning() {
         numberOfJointsParam.setEnabled(true);
         jointMatricesParam.setEnabled(true);
 
-        // Next full 10 bones (e.g. 30 on 24 bones)
+        // Calculate the number of bones rounded up to the nearest multiple of 10.
+        // This is often required by shaders for array uniform declarations.
         int numBones = ((armature.getJointCount() / 10) + 1) * 10;
         numberOfJointsParam.setValue(numBones);
 
@@ -150,7 +172,10 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         }
     }
 
-    private void switchToSoftware() {
+    /**
+     * Configures the material parameters and meshes for software skinning.
+     */
+    private void enableSoftwareSkinning() {
         numberOfJointsParam.setEnabled(false);
         jointMatricesParam.setEnabled(false);
 
@@ -162,22 +187,34 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         }
     }
 
-    private boolean testHardwareSupported(RenderManager rm) {
-
-        //Only 255 bones max supported with hardware skinning
-        if (armature.getJointCount() > 255) {
+    /**
+     * Tests if hardware skinning is supported by the GPU for the current spatial.
+     *
+     * @param renderManager the RenderManager instance
+     * @return true if hardware skinning is supported, false otherwise
+     */
+    private boolean testHardwareSupported(RenderManager renderManager) {
+        // Only 255 bones max supported with hardware skinning in common shaders.
+        if (armature.getJointCount() > MAX_BONES_HW_SKINNING_SUPPORT) {
+            logger.log(Level.INFO, "Hardware skinning not supported for {0}: Too many bones ({1} > 255).",
+                    new Object[]{spatial, armature.getJointCount()});
             return false;
         }
 
-        switchToHardware();
+        // Temporarily enable hardware skinning to test shader compilation.
+        enableHardwareSkinning();
+        boolean hwSkinningEngaged = false;
 
         try {
-            rm.preloadScene(spatial);
-            return true;
-        } catch (RendererException e) {
-            logger.log(Level.WARNING, "Could not enable HW skinning due to shader compile error:", e);
-            return false;
+            renderManager.preloadScene(spatial);
+            logger.log(Level.INFO, "Hardware skinning engaged for {0}", spatial);
+            hwSkinningEngaged = true;
+
+        } catch (RendererException ex) {
+            logger.log(Level.WARNING, "Could not enable HW skinning due to shader compile error: ", ex);
         }
+
+        return hwSkinningEngaged;
     }
 
     /**
@@ -190,7 +227,7 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      * @see #isHardwareSkinningUsed()
      */
     public void setHardwareSkinningPreferred(boolean preferred) {
-        hwSkinningDesired = preferred;
+        hwSkinningPreferred = preferred;
     }
 
     /**
@@ -199,7 +236,7 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      * @see #setHardwareSkinningPreferred(boolean)
      */
     public boolean isHardwareSkinningPreferred() {
-        return hwSkinningDesired;
+        return hwSkinningPreferred;
     }
 
     /**
@@ -209,25 +246,21 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         return hwSkinningEnabled;
     }
 
-
     /**
-     * If specified the geometry has an animated mesh, add its mesh and material
-     * to the lists of animation targets.
+     * Recursively finds and adds animated geometries to the targets list.
+     *
+     * @param sp The spatial to search within.
      */
-    private void findTargets(Geometry geometry) {
-        Mesh mesh = geometry.getMesh();
-        if (mesh != null && mesh.isAnimated()) {
-            targets.add(geometry);
-        }
-
-    }
-
-    private void findTargets(Node node) {
-        for (Spatial child : node.getChildren()) {
-            if (child instanceof Geometry) {
-                findTargets((Geometry) child);
-            } else if (child instanceof Node) {
-                findTargets((Node) child);
+    private void collectAnimatedGeometries(Spatial sp) {
+        if (sp instanceof Geometry) {
+            Geometry geo = (Geometry) sp;
+            Mesh mesh = geo.getMesh();
+            if (mesh != null && mesh.isAnimated()) {
+                targets.add(geo);
+            }
+        } else if (sp instanceof Node) {
+            for (Spatial child : ((Node) sp).getChildren()) {
+                collectAnimatedGeometries(child);
             }
         }
     }
@@ -236,65 +269,72 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     public void setSpatial(Spatial spatial) {
         Spatial oldSpatial = this.spatial;
         super.setSpatial(spatial);
-        updateTargetsAndMaterials(spatial);
+        updateAnimationTargets(spatial);
 
         if (oldSpatial != null) {
+            // Ensure parameters are removed from the old spatial to prevent memory leaks
             oldSpatial.removeMatParamOverride(numberOfJointsParam);
             oldSpatial.removeMatParamOverride(jointMatricesParam);
         }
 
         if (spatial != null) {
-            spatial.removeMatParamOverride(numberOfJointsParam);
-            spatial.removeMatParamOverride(jointMatricesParam);
+            // Add parameters to the new spatial. No need to remove first if they are not already present.
             spatial.addMatParamOverride(numberOfJointsParam);
             spatial.addMatParamOverride(jointMatricesParam);
         }
     }
 
+    /**
+     * Performs software skinning updates.
+     */
     private void controlRenderSoftware() {
         resetToBind(); // reset morph meshes to bind pose
 
-        offsetMatrices = armature.computeSkinningMatrices();
+        boneOffsetMatrices = armature.computeSkinningMatrices();
 
         for (Geometry geometry : targets) {
             Mesh mesh = geometry.getMesh();
-            // NOTE: This assumes code higher up has
-            // already ensured this mesh is animated.
-            // Otherwise a crash will happen in skin update.
-            softwareSkinUpdate(mesh, offsetMatrices);
+            // NOTE: This assumes code higher up has already ensured this mesh is animated.
+            // Otherwise, a crash will happen in skin update.
+            applySoftwareSkinning(mesh, boneOffsetMatrices);
         }
     }
 
+    /**
+     * Prepares parameters for hardware skinning.
+     */
     private void controlRenderHardware() {
-        offsetMatrices = armature.computeSkinningMatrices();
-        jointMatricesParam.setValue(offsetMatrices);
+        boneOffsetMatrices = armature.computeSkinningMatrices();
+        jointMatricesParam.setValue(boneOffsetMatrices);
     }
 
     @Override
     protected void controlRender(RenderManager rm, ViewPort vp) {
-        if (!wasMeshUpdated) {
-            updateTargetsAndMaterials(spatial);
+        if (meshUpdateRequired) {
+            updateAnimationTargets(spatial);
 
             // Prevent illegal cases. These should never happen.
-            assert hwSkinningTested || (!hwSkinningTested && !hwSkinningSupported && !hwSkinningEnabled);
-            assert !hwSkinningEnabled || (hwSkinningEnabled && hwSkinningTested && hwSkinningSupported);
+            assert hwSkinningTested || (!hwSkinningSupported && !hwSkinningEnabled);
+            assert !hwSkinningEnabled || (hwSkinningTested && hwSkinningSupported);
 
-            if (hwSkinningDesired && !hwSkinningTested) {
+            if (hwSkinningPreferred && !hwSkinningTested) {
+                // If hardware skinning is preferred and hasn't been tested yet, test it.
                 hwSkinningTested = true;
                 hwSkinningSupported = testHardwareSupported(rm);
 
                 if (hwSkinningSupported) {
                     hwSkinningEnabled = true;
-
-                    Logger.getLogger(SkinningControl.class.getName()).log(Level.INFO, "Hardware skinning engaged for {0}", spatial);
                 } else {
-                    switchToSoftware();
+                    enableSoftwareSkinning();
                 }
-            } else if (hwSkinningDesired && hwSkinningSupported && !hwSkinningEnabled) {
-                switchToHardware();
+            } else if (hwSkinningPreferred && hwSkinningSupported && !hwSkinningEnabled) {
+                // If hardware skinning is preferred, supported, but not yet enabled, enable it.
+                enableHardwareSkinning();
                 hwSkinningEnabled = true;
-            } else if (!hwSkinningDesired && hwSkinningEnabled) {
-                switchToSoftware();
+
+            } else if (!hwSkinningPreferred && hwSkinningEnabled) {
+                // If hardware skinning is no longer preferred but is enabled, switch to software.
+                enableSoftwareSkinning();
                 hwSkinningEnabled = false;
             }
 
@@ -304,17 +344,22 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
                 controlRenderSoftware();
             }
 
-            wasMeshUpdated = true;
+            meshUpdateRequired = false; // Reset flag after update
         }
     }
 
     @Override
     protected void controlUpdate(float tpf) {
-        wasMeshUpdated = false;
+        meshUpdateRequired = true; // Mark for mesh update on next render pass
         armature.update();
     }
 
-    //only do this for software updates
+    /**
+     * Resets the vertex, normal, and tangent buffers of animated meshes to their
+     * original bind pose. This is crucial for software skinning to ensure
+     * transformations are applied from a consistent base.
+     * This method is only applied when performing software updates.
+     */
     void resetToBind() {
         for (Geometry geometry : targets) {
             Mesh mesh = geometry.getMesh();
@@ -378,51 +423,51 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Access the attachments node of the named bone. If the bone doesn't
-     * already have an attachments node, create one and attach it to the scene
-     * graph. Models and effects attached to the attachments node will follow
-     * the bone's motions.
+     * Provides access to the attachment node for a specific joint in the armature.
+     * If an attachment node does not already exist for the named joint, one will be
+     * created and attached to the scene graph. Models or effects attached to this
+     * node will follow the motion of the corresponding bone.
      *
      * @param jointName the name of the joint
      * @return the attachments node of the joint
      */
     public Node getAttachmentsNode(String jointName) {
-        Joint b = armature.getJoint(jointName);
-        if (b == null) {
-            throw new IllegalArgumentException("Given bone name does not exist "
-                    + "in the armature.");
+        Joint joint = armature.getJoint(jointName);
+        if (joint == null) {
+            throw new IllegalArgumentException(
+                    "Given joint name '" + jointName + "' does not exist in the armature.");
         }
 
-        updateTargetsAndMaterials(spatial);
-        int boneIndex = armature.getJointIndex(b);
-        Node n = b.getAttachmentsNode(boneIndex, targets);
-        /*
-         * Select a node to parent the attachments node.
-         */
+        updateAnimationTargets(spatial);
+        int jointIndex = armature.getJointIndex(joint);
+        Node attachNode = joint.getAttachmentsNode(jointIndex, targets);
+
+        // Determine the appropriate parent for the attachment node.
         Node parent;
         if (spatial instanceof Node) {
             parent = (Node) spatial; // the usual case
         } else {
             parent = spatial.getParent();
         }
-        parent.attachChild(n);
+        parent.attachChild(attachNode);
 
-        return n;
+        return attachNode;
     }
 
     /**
-     * returns the armature of this control
+     * Returns the armature associated with this skinning control.
      *
-     * @return the pre-existing instance
+     * @return The pre-existing `Armature` instance.
      */
     public Armature getArmature() {
         return armature;
     }
 
     /**
-     * Enumerate the target meshes of this control.
+     * Returns an array containing all the target meshes that this control
+     * is currently affecting.
      *
-     * @return a new array
+     * @return A new array of `Mesh` objects.
      */
     public Mesh[] getTargets() {
         Mesh[] result = new Mesh[targets.size()];
@@ -437,30 +482,31 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Update the mesh according to the given transformation matrices
+     * Applies software skinning transformations to the given mesh using the
+     * provided bone offset matrices.
      *
-     * @param mesh           then mesh
-     * @param offsetMatrices the transformation matrices to apply
+     * @param mesh           The mesh to deform.
+     * @param offsetMatrices The array of transformation matrices for each bone.
      */
-    private void softwareSkinUpdate(Mesh mesh, Matrix4f[] offsetMatrices) {
+    private void applySoftwareSkinning(Mesh mesh, Matrix4f[] offsetMatrices) {
 
         VertexBuffer tb = mesh.getBuffer(Type.Tangent);
         if (tb == null) {
-            //if there are no tangents use the classic skinning
+            // if there are no tangents use the classic skinning
             applySkinning(mesh, offsetMatrices);
         } else {
-            //if there are tangents use the skinning with tangents
+            // if there are tangents use the skinning with tangents
             applySkinningTangents(mesh, offsetMatrices, tb);
         }
-
-
     }
 
     /**
-     * Method to apply skinning transforms to a mesh's buffers
+     * Applies skinning transformations to a mesh's position and normal buffers.
+     * This method iterates through each vertex, applies the weighted sum of
+     * bone transformations, and updates the vertex buffers.
      *
-     * @param mesh           the mesh
-     * @param offsetMatrices the offset matrices to apply
+     * @param mesh           The mesh to apply skinning to.
+     * @param offsetMatrices The bone offset matrices to use for transformation.
      */
     private void applySkinning(Mesh mesh, Matrix4f[] offsetMatrices) {
         int maxWeightsPerVert = mesh.getMaxNumWeights();
@@ -555,19 +601,16 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
 
         vb.updateData(fvb);
         nb.updateData(fnb);
-
     }
 
     /**
-     * Specific method for skinning with tangents to avoid cluttering the
-     * classic skinning calculation with null checks that would slow down the
-     * process even if tangents don't have to be computed. Also the iteration
-     * has additional indexes since tangent has 4 components instead of 3 for
-     * pos and norm
+     * Applies skinning transformations to a mesh's position, normal, and tangent buffers.
+     * This method is specifically designed for meshes that include tangent data,
+     * ensuring proper deformation of tangents alongside positions and normals.
      *
-     * @param mesh           the mesh
-     * @param offsetMatrices the offsetMatrices to apply
-     * @param tb             the tangent vertexBuffer
+     * @param mesh           The mesh to apply skinning to.
+     * @param offsetMatrices The bone offset matrices to use for transformation.
+     * @param tb             The tangent `VertexBuffer`.
      */
     private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexBuffer tb) {
         int maxWeightsPerVert = mesh.getMaxNumWeights();
@@ -594,7 +637,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         FloatBuffer ftb = (FloatBuffer) tb.getData();
         ftb.rewind();
 
-
         // get boneIndexes and weights for mesh
         IndexBuffer ib = IndexBuffer.wrapIndexBuffer(mesh.getBuffer(Type.BoneIndex).getData());
         FloatBuffer wb = (FloatBuffer) mesh.getBuffer(Type.BoneWeight).getData();
@@ -605,8 +647,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         int idxWeights = 0;
 
         TempVars vars = TempVars.get();
-
-
         float[] posBuf = vars.skinPositions;
         float[] normBuf = vars.skinNormals;
         float[] tanBuf = vars.skinTangents;
@@ -723,9 +763,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         super.write(ex);
         OutputCapsule oc = ex.getCapsule(this);
         oc.write(armature, "armature", null);
-
-        oc.write(numberOfJointsParam, "numberOfBonesParam", null);
-        oc.write(jointMatricesParam, "boneMatricesParam", null);
     }
 
     /**
@@ -741,15 +778,13 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         InputCapsule in = im.getCapsule(this);
         armature = (Armature) in.readSavable("armature", null);
 
-        numberOfJointsParam = (MatParamOverride) in.readSavable("numberOfBonesParam", null);
-        jointMatricesParam = (MatParamOverride) in.readSavable("boneMatricesParam", null);
-
-        if (numberOfJointsParam == null) {
-            numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
-            jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
-            getSpatial().addMatParamOverride(numberOfJointsParam);
-            getSpatial().addMatParamOverride(jointMatricesParam);
+        for (MatParamOverride mpo : spatial.getLocalMatParamOverrides().getArray()) {
+            if (mpo.getName().equals("NumberOfBones") || mpo.getName().equals("BoneMatrices")) {
+                spatial.removeMatParamOverride(mpo);
+            }
         }
+        spatial.addMatParamOverride(numberOfJointsParam);
+        spatial.addMatParamOverride(jointMatricesParam);
     }
 
     /**
@@ -757,13 +792,9 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      *
      * @param spatial the controlled spatial
      */
-    private void updateTargetsAndMaterials(Spatial spatial) {
+    private void updateAnimationTargets(Spatial spatial) {
         targets.clear();
-
-        if (spatial instanceof Node) {
-            findTargets((Node) spatial);
-        } else if (spatial instanceof Geometry) {
-            findTargets((Geometry) spatial);
-        }
+        collectAnimatedGeometries(spatial);
     }
+
 }

+ 2 - 3
jme3-core/src/main/java/com/jme3/animation/LoopMode.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -35,7 +35,6 @@ package com.jme3.animation;
  * <code>LoopMode</code> determines how animations repeat, or if they
  * do not repeat.
  */
-@Deprecated
 public enum LoopMode {
     /**
      * The animation will play repeatedly, when it reaches the end
@@ -55,6 +54,6 @@ public enum LoopMode {
      * animation will play backwards from the last frame until it reaches
      * the first frame.
      */
-    Cycle,
+    Cycle
 
 }

+ 75 - 25
jme3-core/src/main/java/com/jme3/app/state/ConstantVerifierState.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2014-2021 jMonkeyEngine
+ * Copyright (c) 2014-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -29,30 +29,39 @@
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
-
 package com.jme3.app.state;
 
-import java.util.Arrays;
-import java.util.logging.Logger;
-
 import com.jme3.app.Application;
-import com.jme3.math.*;
+import com.jme3.math.Matrix3f;
+import com.jme3.math.Matrix4f;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import com.jme3.math.Vector4f;
 import com.jme3.util.SafeArrayList;
 
+import java.util.Arrays;
+import java.util.logging.Logger;
+
+import static java.lang.Float.NEGATIVE_INFINITY;
 import static java.lang.Float.NaN;
 import static java.lang.Float.POSITIVE_INFINITY;
-import static java.lang.Float.NEGATIVE_INFINITY;
 
 /**
- *  Checks the various JME 'constants' for drift using either asserts
- *  or straight checks.  The list of constants can also be configured
- *  but defaults to the standard JME Vector3f, Quaternion, etc. constants.
+ * An AppState that periodically checks the values of various JME math constants
+ * (e.g., `Vector3f.ZERO`, `Quaternion.IDENTITY`) against their known good values.
+ * This is useful for detecting accidental modifications or "drift" of these
+ * supposedly immutable constants during application runtime.
+ * <p>
+ * The state can be configured to report discrepancies using asserts,
+ * throwing runtime exceptions, or logging severe messages.
+ * The set of constants to check is configurable.
  *
- *  @author    Paul Speed
+ * @author Paul Speed
  */
 public class ConstantVerifierState extends BaseAppState {
 
-    private static final Logger log = Logger.getLogger(BaseAppState.class.getName());
+    private static final Logger log = Logger.getLogger(ConstantVerifierState.class.getName());
 
     // Note: I've used actual constructed objects for the good values
     //       instead of clone just to better catch cases where the values
@@ -73,7 +82,14 @@ public class ConstantVerifierState extends BaseAppState {
                     new Quaternion().fromAxes(Vector3f.UNIT_X, Vector3f.UNIT_Y, Vector3f.UNIT_Z)),
             new Checker(Quaternion.ZERO, new Quaternion(0, 0, 0, 0)),
             new Checker(Vector2f.ZERO, new Vector2f(0f, 0f)),
+            new Checker(Vector2f.NAN, new Vector2f(NaN, NaN)),
+            new Checker(Vector2f.UNIT_X, new Vector2f(1, 0)),
+            new Checker(Vector2f.UNIT_Y, new Vector2f(0, 1)),
             new Checker(Vector2f.UNIT_XY, new Vector2f(1f, 1f)),
+            new Checker(Vector2f.POSITIVE_INFINITY,
+                    new Vector2f(POSITIVE_INFINITY, POSITIVE_INFINITY)),
+            new Checker(Vector2f.NEGATIVE_INFINITY,
+                    new Vector2f(NEGATIVE_INFINITY, NEGATIVE_INFINITY)),
             new Checker(Vector4f.ZERO, new Vector4f(0, 0, 0, 0)),
             new Checker(Vector4f.NAN, new Vector4f(NaN, NaN, NaN, NaN)),
             new Checker(Vector4f.UNIT_X, new Vector4f(1, 0, 0, 0)),
@@ -91,24 +107,34 @@ public class ConstantVerifierState extends BaseAppState {
             new Checker(Matrix4f.IDENTITY, new Matrix4f())
         };
 
-    public enum ErrorType { Assert, Exception, Log };
+    /**
+     * Defines how constant value discrepancies should be reported.
+     */
+    public enum ErrorType {
+        /** Causes an `assert` failure if the constant has changed. Requires assertions to be enabled. */
+        Assert,
+        /** Throws a `RuntimeException` if the constant has changed. */
+        Exception,
+        /** Logs a severe message if the constant has changed. */
+        Log
+    }
 
-    final private SafeArrayList<Checker> checkers = new SafeArrayList<>(Checker.class);
+    private final SafeArrayList<Checker> checkers = new SafeArrayList<>(Checker.class);
     private ErrorType errorType;
 
     /**
-     *  Creates a verifier app state that will check all of the default
-     *  constant checks using asserts.
+     * Creates a verifier app state that will check all of the default
+     * JME math constants using `ErrorType.Assert`.
      */
     public ConstantVerifierState() {
         this(ErrorType.Assert);
     }
 
     /**
-     *  Creates a verifier app state that will check all of the default
-     *  constant checks using the specified error reporting mechanism.
+     * Creates a verifier app state that will check all of the default
+     * JME math constants using the specified error reporting mechanism.
      *
-     * @param errorType the mechanism to use
+     * @param errorType The mechanism to use when a constant's value drifts.
      */
     public ConstantVerifierState(ErrorType errorType) {
         this(errorType, DEFAULT_CHECKS);
@@ -126,14 +152,32 @@ public class ConstantVerifierState extends BaseAppState {
         this.checkers.addAll(Arrays.asList(checkers));
     }
 
+    /**
+     * Adds a new constant and its expected good value to the list of items to be checked.
+     * The `constant` and `goodValue` should be instances of the same class.
+     *
+     * @param constant The JME constant object to monitor for drift (e.g., `Vector3f.ZERO`).
+     * @param goodValue An independent instance representing the expected correct value of the constant.
+     * This instance should match the initial value of `constant`.
+     */
     public void addChecker(Object constant, Object goodValue) {
         checkers.add(new Checker(constant, goodValue));
     }
 
+    /**
+     * Sets the error reporting mechanism to be used when a constant's value drifts.
+     *
+     * @param errorType The desired error reporting type.
+     */
     public void setErrorType(ErrorType errorType) {
         this.errorType = errorType;
     }
 
+    /**
+     * Returns the currently configured error reporting mechanism.
+     *
+     * @return The current `ErrorType`.
+     */
     public ErrorType getErrorType() {
         return errorType;
     }
@@ -161,21 +205,26 @@ public class ConstantVerifierState extends BaseAppState {
         checkValues();
     }
 
+    /**
+     * Iterates through all registered checkers and verifies the current values
+     * of the constants against their known good values.
+     * Reports any discrepancies based on the configured `ErrorType`.
+     */
     protected void checkValues() {
         for (Checker checker : checkers.getArray()) {
             switch (errorType) {
-                default:
+                default: // Fall through to Assert if somehow null
                 case Assert:
                     assert checker.isValid() : checker.toString();
                     break;
                 case Exception:
                     if (!checker.isValid()) {
-                        throw new RuntimeException("Constant has changed, " + checker.toString());
+                        throw new RuntimeException("JME Constant has changed, " + checker.toString());
                     }
                     break;
                 case Log:
                     if (!checker.isValid()) {
-                        log.severe("Constant has changed, " + checker.toString());
+                        log.severe("JME Constant has changed, " + checker.toString());
                     }
                     break;
             }
@@ -188,8 +237,9 @@ public class ConstantVerifierState extends BaseAppState {
      *  mean anything.
      */
     private static class Checker {
-        private Object constant;
-        private Object goodValue;
+
+        private final Object constant;
+        private final Object goodValue;
 
         public Checker(Object constant, Object goodValue) {
             if (constant == null) {
@@ -197,7 +247,7 @@ public class ConstantVerifierState extends BaseAppState {
             }
             if (!constant.equals(goodValue)) {
                 throw new IllegalArgumentException(
-                        "Constant value:" + constant + " does not match value:" + goodValue);
+                        "Constant value: " + constant + " does not match value: " + goodValue);
             }
             this.constant = constant;
             this.goodValue = goodValue;

+ 5 - 1
jme3-core/src/main/java/com/jme3/audio/AudioNode.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -317,6 +317,10 @@ public class AudioNode extends Node implements AudioSource {
         data = audioData;
         this.audioKey = audioKey;
     }
+    
+    public AudioKey getAudioKey() {
+        return audioKey;
+    }
 
     /**
      * @return The {@link AudioData} set previously with

+ 17 - 19
jme3-core/src/main/java/com/jme3/cinematic/events/MotionEvent.java

@@ -52,8 +52,8 @@ import com.jme3.util.clone.JmeCloneable;
 import java.io.IOException;
 
 /**
- * A MotionEvent is a control over the spatial that manages the position and direction of the spatial while following a motion Path.
- *
+ * A MotionEvent is a control over the spatial that manages
+ * the position and direction of the spatial while following a motion Path.
  * You must first create a MotionPath and then create a MotionEvent to associate a spatial and the path.
  *
  * @author Nehon
@@ -70,6 +70,7 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
     protected Direction directionType = Direction.None;
     protected MotionPath path;
     private boolean isControl = true;
+    private final Quaternion tempRotation = new Quaternion();
     /**
      * the distance traveled by the spatial on the path
      */
@@ -79,7 +80,6 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
      * Enum for the different type of target direction behavior.
      */
     public enum Direction {
-
         /**
          * The target stays in the starting direction.
          */
@@ -229,13 +229,13 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
     @Override
     public void read(JmeImporter im) throws IOException {
         super.read(im);
-        InputCapsule in = im.getCapsule(this);
-        lookAt = (Vector3f) in.readSavable("lookAt", null);
-        upVector = (Vector3f) in.readSavable("upVector", Vector3f.UNIT_Y);
-        rotation = (Quaternion) in.readSavable("rotation", null);
-        directionType = in.readEnum("directionType", Direction.class, Direction.None);
-        path = (MotionPath) in.readSavable("path", null);
-        spatial = (Spatial) in.readSavable("spatial", null);
+        InputCapsule ic = im.getCapsule(this);
+        lookAt = (Vector3f) ic.readSavable("lookAt", null);
+        upVector = (Vector3f) ic.readSavable("upVector", Vector3f.UNIT_Y);
+        rotation = (Quaternion) ic.readSavable("rotation", null);
+        directionType = ic.readEnum("directionType", Direction.class, Direction.None);
+        path = (MotionPath) ic.readSavable("path", null);
+        spatial = (Spatial) ic.readSavable("spatial", null);
     }
 
     /**
@@ -249,9 +249,8 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
     private void computeTargetDirection() {
         switch (directionType) {
             case Path:
-                Quaternion q = new Quaternion();
-                q.lookAt(direction, upVector);
-                spatial.setLocalRotation(q);
+                tempRotation.lookAt(direction, upVector);
+                spatial.setLocalRotation(tempRotation);
                 break;
             case LookAt:
                 if (lookAt != null) {
@@ -260,10 +259,9 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
                 break;
             case PathAndRotation:
                 if (rotation != null) {
-                    Quaternion q2 = new Quaternion();
-                    q2.lookAt(direction, upVector);
-                    q2.multLocal(rotation);
-                    spatial.setLocalRotation(q2);
+                    tempRotation.lookAt(direction, upVector);
+                    tempRotation.multLocal(rotation);
+                    spatial.setLocalRotation(tempRotation);
                 }
                 break;
             case Rotation:
@@ -272,6 +270,7 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
                 }
                 break;
             case None:
+                // no-op
                 break;
             default:
                 break;
@@ -376,8 +375,7 @@ public class MotionEvent extends AbstractCinematicEvent implements Control, JmeC
 
     /**
      * Sets the direction of the spatial, using the Y axis as the up vector.
-     * Use MotionEvent#setDirection((Vector3f direction,Vector3f upVector) if
-     * you want a custom up vector.
+     * If a custom up vector is desired, use {@link #setDirection(Vector3f, Vector3f)}.
      * This method is used by the motion path.
      *
      * @param direction the desired forward direction (not null, unaffected)

+ 1 - 1
jme3-core/src/main/java/com/jme3/effect/ParticleEmitter.java

@@ -1124,7 +1124,7 @@ public class ParticleEmitter extends Geometry {
         lastPos.set(getWorldTranslation());
 
         //This check avoids a NaN bounds when all the particles are dead during the first update.
-        if (!min.equals(Vector3f.POSITIVE_INFINITY) && !max.equals(Vector3f.NEGATIVE_INFINITY)) {
+        if (Vector3f.isValidVector(min) && Vector3f.isValidVector(max)) {
             BoundingBox bbox = (BoundingBox) this.getMesh().getBound();
             bbox.setMinMax(min, max);
             this.setBoundRefresh();

+ 92 - 39
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterMeshFaceShape.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -36,6 +36,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Mesh;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.util.BufferUtils;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -52,79 +53,131 @@ public class EmitterMeshFaceShape extends EmitterMeshVertexShape {
     }
 
     /**
-     * Constructor. It stores a copy of vertex list of all meshes.
-     * @param meshes
-     *        a list of meshes that will form the emitter's shape
+     * Constructor. Initializes the emitter shape with a list of meshes.
+     * The vertices and normals for all triangles of these meshes are
+     * extracted and stored internally.
+     *
+     * @param meshes a list of {@link Mesh} objects that will define the
+     * shape from which particles are emitted.
      */
     public EmitterMeshFaceShape(List<Mesh> meshes) {
         super(meshes);
     }
 
+    /**
+     * Sets the meshes for this emitter shape. This method extracts all
+     * triangle vertices and computes their normals, storing them internally
+     * for subsequent particle emission.
+     *
+     * @param meshes a list of {@link Mesh} objects to set as the emitter's shape.
+     */
     @Override
     public void setMeshes(List<Mesh> meshes) {
         this.vertices = new ArrayList<List<Vector3f>>(meshes.size());
         this.normals = new ArrayList<List<Vector3f>>(meshes.size());
+
         for (Mesh mesh : meshes) {
             Vector3f[] vertexTable = BufferUtils.getVector3Array(mesh.getFloatBuffer(Type.Position));
             int[] indices = new int[3];
-            List<Vector3f> vertices = new ArrayList<>(mesh.getTriangleCount() * 3);
-            List<Vector3f> normals = new ArrayList<>(mesh.getTriangleCount());
+            List<Vector3f> meshVertices = new ArrayList<>(mesh.getTriangleCount() * 3);
+            List<Vector3f> meshNormals = new ArrayList<>(mesh.getTriangleCount());
+
             for (int i = 0; i < mesh.getTriangleCount(); ++i) {
                 mesh.getTriangle(i, indices);
-                vertices.add(vertexTable[indices[0]]);
-                vertices.add(vertexTable[indices[1]]);
-                vertices.add(vertexTable[indices[2]]);
-                normals.add(FastMath.computeNormal(vertexTable[indices[0]], vertexTable[indices[1]], vertexTable[indices[2]]));
+
+                Vector3f v1 = vertexTable[indices[0]];
+                Vector3f v2 = vertexTable[indices[1]];
+                Vector3f v3 = vertexTable[indices[2]];
+
+                // Add all three vertices of the triangle
+                meshVertices.add(v1);
+                meshVertices.add(v2);
+                meshVertices.add(v3);
+
+                // Compute and add the normal for the current triangle face
+                meshNormals.add(FastMath.computeNormal(v1, v2, v3));
             }
-            this.vertices.add(vertices);
-            this.normals.add(normals);
+            this.vertices.add(meshVertices);
+            this.normals.add(meshNormals);
         }
     }
 
     /**
-     * Randomly selects a point on a random face.
+     * Randomly selects a point on a random face of one of the stored meshes.
+     * The point is generated using barycentric coordinates to ensure uniform
+     * distribution within the selected triangle.
      *
-     * @param store
-     *        storage for the coordinates of the selected point
+     * @param store a {@link Vector3f} object where the coordinates of the
+     *              selected point will be stored.
      */
     @Override
     public void getRandomPoint(Vector3f store) {
         int meshIndex = FastMath.nextRandomInt(0, vertices.size() - 1);
+        List<Vector3f> currVertices = vertices.get(meshIndex);
+        int numVertices = currVertices.size();
+
         // the index of the first vertex of a face (must be dividable by 3)
-        int vertIndex = FastMath.nextRandomInt(0, vertices.get(meshIndex).size() / 3 - 1) * 3;
-        // put the point somewhere between the first and the second vertex of a face
-        float moveFactor = FastMath.nextRandomFloat();
-        store.set(Vector3f.ZERO);
-        store.addLocal(vertices.get(meshIndex).get(vertIndex));
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 1).x - vertices.get(meshIndex).get(vertIndex).x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).y - vertices.get(meshIndex).get(vertIndex).y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).z - vertices.get(meshIndex).get(vertIndex).z) * moveFactor);
-        // move the result towards the last face vertex
-        moveFactor = FastMath.nextRandomFloat();
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 2).x - store.x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).y - store.y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).z - store.z) * moveFactor);
+        int faceIndex = FastMath.nextRandomInt(0, numVertices / 3 - 1);
+        int vertIndex = faceIndex * 3;
+
+        // Generate the random point on the triangle
+        generateRandomPointOnTriangle(currVertices, vertIndex, store);
     }
 
     /**
-     * Randomly selects a point on a random face.
-     * The {@code normal} argument is set to the normal of the selected face.
+     * Randomly selects a point on a random face of one of the stored meshes,
+     * and also sets the normal of that selected face.
+     * The point is generated using barycentric coordinates for uniform distribution.
      *
-     * @param store
-     *        storage for the coordinates of the selected point
-     * @param normal
-     *        storage for the normal of the selected face
+     * @param store  a {@link Vector3f} object where the coordinates of the
+     *               selected point will be stored.
+     * @param normal a {@link Vector3f} object where the normal of the
+     *               selected face will be stored.
      */
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
         int meshIndex = FastMath.nextRandomInt(0, vertices.size() - 1);
+        List<Vector3f> currVertices = vertices.get(meshIndex);
+        int numVertices = currVertices.size();
+
         // the index of the first vertex of a face (must be dividable by 3)
-        int faceIndex = FastMath.nextRandomInt(0, vertices.get(meshIndex).size() / 3 - 1);
+        int faceIndex = FastMath.nextRandomInt(0, numVertices / 3 - 1);
         int vertIndex = faceIndex * 3;
-        // put the point somewhere between the first and the second vertex of a face
-        float moveFactor = FastMath.nextRandomFloat();
-        store.set(Vector3f.ZERO);
-        store.addLocal(vertices.get(meshIndex).get(vertIndex));
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 1).x - vertices.get(meshIndex).get(vertIndex).x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).y - vertices.get(meshIndex).get(vertIndex).y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).z - vertices.get(meshIndex).get(vertIndex).z) * moveFactor);
-        // move the result towards the last face vertex
-        moveFactor = FastMath.nextRandomFloat();
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 2).x - store.x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).y - store.y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).z - store.z) * moveFactor);
+
+        // Generate the random point on the triangle
+        generateRandomPointOnTriangle(currVertices, vertIndex, store);
+        // Set the normal from the pre-computed normals list for the selected face
         normal.set(normals.get(meshIndex).get(faceIndex));
     }
+
+    /**
+     * Internal method to generate a random point within a specific triangle
+     * using barycentric coordinates.
+     *
+     * @param currVertices The list of vertices for the current mesh.
+     * @param vertIndex    The starting index of the triangle's first vertex
+     *                     within the {@code currVertices} list.
+     * @param store        A {@link Vector3f} object where the calculated point will be stored.
+     */
+    private void generateRandomPointOnTriangle(List<Vector3f> currVertices, int vertIndex, Vector3f store) {
+
+        Vector3f v1 = currVertices.get(vertIndex);
+        Vector3f v2 = currVertices.get(vertIndex + 1);
+        Vector3f v3 = currVertices.get(vertIndex + 2);
+
+        // Generate random barycentric coordinates
+        float u = FastMath.nextRandomFloat();
+        float v = FastMath.nextRandomFloat();
+
+        if ((u + v) > 1) {
+            u = 1 - u;
+            v = 1 - v;
+        }
+
+        // P = v1 + u * (v2 - v1) + v * (v3 - v1)
+        store.x = v1.x + u * (v2.x - v1.x) + v * (v3.x - v1.x);
+        store.y = v1.y + u * (v2.y - v1.y) + v * (v3.y - v1.y);
+        store.z = v1.z + u * (v2.z - v1.z) + v * (v3.z - v1.z);
+    }
+
 }

+ 1 - 0
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterSphereShape.java

@@ -137,6 +137,7 @@ public class EmitterSphereShape implements EmitterShape {
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
         this.getRandomPoint(store);
+        normal.set(store).subtractLocal(center).normalizeLocal();
     }
 
     /**

+ 167 - 0
jme3-core/src/main/java/com/jme3/environment/util/Circle.java

@@ -0,0 +1,167 @@
+ /*
+  * Copyright (c) 2009-2025 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 com.jme3.environment.util;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.material.Material;
+import com.jme3.material.RenderState;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.VertexBuffer.Type;
+import com.jme3.util.BufferUtils;
+
+import java.io.IOException;
+import java.nio.FloatBuffer;
+import java.nio.ShortBuffer;
+
+/**
+ * <p>A `Circle` is a 2D mesh representing a circular outline (wireframe).
+ * It's defined by a specified number of radial samples, which determine its smoothness.</p>
+ *
+ * <p>The circle is centered at (0,0,0) in its local coordinate space and has a radius of 1.0.</p>
+ *
+ * @author capdevon
+ */
+public class Circle extends Mesh {
+
+    // The number of segments used to approximate the circle.
+    protected int radialSamples = 256;
+
+    /**
+     * Creates a new `Circle` mesh.
+     */
+    public Circle() {
+        setGeometryData();
+        setIndexData();
+    }
+
+    /**
+     * Initializes the vertex buffers for the circle mesh.
+     */
+    private void setGeometryData() {
+
+        int numVertices = radialSamples + 1;
+
+        FloatBuffer posBuf = BufferUtils.createVector3Buffer(numVertices);
+        FloatBuffer colBuf = BufferUtils.createFloatBuffer(numVertices * 4);
+        FloatBuffer texBuf = BufferUtils.createVector2Buffer(numVertices);
+
+        // --- Generate Geometry Data ---
+        float angleStep = FastMath.TWO_PI / radialSamples;
+
+        // Define the color for the entire circle.
+        ColorRGBA color = ColorRGBA.Orange;
+
+        // Populate the position, color, and texture coordinate buffers.
+        for (int i = 0; i < numVertices; i++) {
+            float angle = angleStep * i;
+            float cos = FastMath.cos(angle);
+            float sin = FastMath.sin(angle);
+
+            posBuf.put(cos).put(sin).put(0);
+            colBuf.put(color.r).put(color.g).put(color.b).put(color.a);
+            texBuf.put(i % 2f).put(i % 2f);
+        }
+
+        setBuffer(Type.Position, 3, posBuf);
+        setBuffer(Type.Color, 4, colBuf);
+        setBuffer(Type.TexCoord, 2, texBuf);
+
+        setMode(Mode.Lines);
+        updateBound();
+        setStatic();
+    }
+
+    /**
+     * Initializes the index buffer for the circle mesh.
+     */
+    private void setIndexData() {
+        // allocate connectivity
+        int numIndices = radialSamples * 2;
+
+        ShortBuffer idxBuf = BufferUtils.createShortBuffer(numIndices);
+        setBuffer(Type.Index, 2, idxBuf);
+
+        // --- Generate Index Data ---
+        for (int i = 0; i < radialSamples; i++) {
+            idxBuf.put((short) i);         // Start of segment
+            idxBuf.put((short) (i + 1));   // End of segment
+        }
+    }
+
+    /**
+     * Creates a {@link Geometry} object representing a dashed wireframe circle.
+     *
+     * @param assetManager The application's AssetManager to load materials.
+     * @param name         The desired name for the Geometry.
+     * @return A new Geometry instance with a `Circle` mesh.
+     */
+    public static Geometry createShape(AssetManager assetManager, String name) {
+        Circle mesh = new Circle();
+        Geometry geom = new Geometry(name, mesh);
+        geom.setQueueBucket(RenderQueue.Bucket.Transparent);
+
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Dashed.j3md");
+        mat.getAdditionalRenderState().setWireframe(true);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        mat.getAdditionalRenderState().setDepthWrite(false);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        mat.getAdditionalRenderState().setLineWidth(2f);
+        mat.setColor("Color", ColorRGBA.Orange);
+        mat.setFloat("DashSize", 0.5f);
+        geom.setMaterial(mat);
+
+        return geom;
+    }
+
+    @Override
+    public void write(JmeExporter ex) throws IOException {
+        super.write(ex);
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(radialSamples, "radialSamples", 256);
+    }
+
+    @Override
+    public void read(JmeImporter im) throws IOException {
+        super.read(im);
+        InputCapsule ic = im.getCapsule(this);
+        radialSamples = ic.readInt("radialSamples", 256);
+    }
+
+}

+ 359 - 86
jme3-core/src/main/java/com/jme3/environment/util/LightsDebugState.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -33,154 +33,427 @@ package com.jme3.environment.util;
 
 import com.jme3.app.Application;
 import com.jme3.app.state.BaseAppState;
-import com.jme3.light.*;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.Light;
+import com.jme3.light.LightProbe;
+import com.jme3.light.PointLight;
+import com.jme3.light.SpotLight;
 import com.jme3.material.Material;
+import com.jme3.material.RenderState;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
+import com.jme3.scene.control.BillboardControl;
+import com.jme3.scene.debug.Arrow;
+import com.jme3.scene.shape.Quad;
 import com.jme3.scene.shape.Sphere;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
+import com.jme3.texture.Texture;
+
+import java.util.ArrayDeque;
+import java.util.Iterator;
 import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.function.Predicate;
 
 /**
- * A debug state that will display Light gizmos on screen.
- * Still a wip and for now it only displays light probes.
+ * A debug state that visualizes different types of lights in the scene with gizmos.
+ * This state is useful for debugging light positions, ranges, and other properties.
  *
  * @author nehon
+ * @author capdevon
  */
 public class LightsDebugState extends BaseAppState {
 
-    private Node debugNode;
-    private final Map<LightProbe, Node> probeMapping = new HashMap<>();
-    private final List<LightProbe> garbage = new ArrayList<>();
-    private Geometry debugGeom;
-    private Geometry debugBounds;
+    private static final String PROBE_GEOMETRY_NAME = "DebugProbeGeometry";
+    private static final String PROBE_BOUNDS_NAME = "DebugProbeBounds";
+    private static final String SPOT_LIGHT_INNER_RADIUS_NAME = "SpotLightInnerRadius";
+    private static final String SPOT_LIGHT_OUTER_RADIUS_NAME = "SpotLightOuterRadius";
+    private static final String SPOT_LIGHT_RADIUS_NAME = "RadiusNode";
+    private static final String POINT_LIGHT_RADIUS_NAME = "PointLightRadius";
+    private static final String LIGHT_DIR_ARROW_NAME = "LightDirection";
+
+    private final Map<Light, Spatial> lightGizmoMap = new WeakHashMap<>();
+    private final ArrayDeque<Light> lightDeque = new ArrayDeque<>();
+    private Predicate<Light> lightFilter = x -> true; // Identity Function
+
+    private ViewPort viewPort;
+    private AssetManager assetManager;
     private Material debugMaterial;
-    private float probeScale = 1.0f;
-    private Spatial scene = null;
-    private final List<LightProbe> probes = new ArrayList<>();
+    private Node debugNode;
+    private Spatial scene; // The scene whose lights will be debugged
+
+    private boolean showOnTop = true;
+    private float lightProbeScale = 1.0f;
+    private final ColorRGBA debugColor = ColorRGBA.DarkGray;
+    private final Quaternion tempRotation = new Quaternion();
 
     @Override
     protected void initialize(Application app) {
-        debugNode = new Node("Environment debug Node");
-        Sphere s = new Sphere(16, 16, 0.15f);
-        debugGeom = new Geometry("debugEnvProbe", s);
-        debugMaterial = new Material(app.getAssetManager(), "Common/MatDefs/Misc/reflect.j3md");
-        debugGeom.setMaterial(debugMaterial);
-        debugBounds = BoundingSphereDebug.createDebugSphere(app.getAssetManager());
+
+        viewPort = app.getRenderManager().createMainView("LightsDebugView", app.getCamera());
+        viewPort.setClearFlags(false, showOnTop, true);
+
+        assetManager = app.getAssetManager();
+        debugNode = new Node("LightsDebugNode");
+
+        debugMaterial = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        debugMaterial.setColor("Color", debugColor);
+        debugMaterial.getAdditionalRenderState().setWireframe(true);
+
         if (scene == null) {
             scene = app.getViewPort().getScenes().get(0);
         }
     }
 
+    private Spatial createBulb() {
+        Quad q = new Quad(0.5f, 0.5f);
+        Geometry lightBulb = new Geometry("LightBulb", q);
+        lightBulb.move(-q.getHeight() / 2f, -q.getWidth() / 2f, 0);
+
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        Texture tex = assetManager.loadTexture("Common/Textures/lightbulb32.png");
+        mat.setTexture("ColorMap", tex);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        lightBulb.setMaterial(mat);
+        lightBulb.setQueueBucket(RenderQueue.Bucket.Transparent);
+
+        Node billboard = new Node("Billboard");
+        billboard.addControl(new BillboardControl());
+        billboard.attachChild(lightBulb);
+
+        return billboard;
+    }
+
+    private Geometry createRadiusShape(String name, float dashSize) {
+        Geometry radius = Circle.createShape(assetManager, name);
+        Material mat = radius.getMaterial();
+        mat.setColor("Color", debugColor);
+        mat.setFloat("DashSize", dashSize);
+        return radius;
+    }
+
+    private Spatial createPointGizmo() {
+        Node gizmo = new Node("PointLightNode");
+        gizmo.attachChild(createBulb());
+
+        Geometry radius = new Geometry(POINT_LIGHT_RADIUS_NAME, new BoundingSphereDebug());
+        radius.setMaterial(debugMaterial);
+        gizmo.attachChild(radius);
+
+        return gizmo;
+    }
+
+    private Spatial createDirectionalGizmo() {
+        Node gizmo = new Node("DirectionalLightNode");
+        gizmo.move(0, 5, 0);
+        gizmo.attachChild(createBulb());
+
+        Geometry arrow = new Geometry(LIGHT_DIR_ARROW_NAME, new Arrow(Vector3f.UNIT_Z.mult(5f)));
+        arrow.setMaterial(debugMaterial);
+        gizmo.attachChild(arrow);
+
+        return gizmo;
+    }
+
+    private Spatial createSpotGizmo() {
+        Node gizmo = new Node("SpotLightNode");
+        gizmo.attachChild(createBulb());
+
+        Node radiusNode = new Node(SPOT_LIGHT_RADIUS_NAME);
+        gizmo.attachChild(radiusNode);
+
+        Geometry inRadius = createRadiusShape(SPOT_LIGHT_INNER_RADIUS_NAME, 0.725f);
+        radiusNode.attachChild(inRadius);
+
+        Geometry outRadius = createRadiusShape(SPOT_LIGHT_OUTER_RADIUS_NAME, 0.325f);
+        radiusNode.attachChild(outRadius);
+
+        Geometry arrow = new Geometry(LIGHT_DIR_ARROW_NAME, new Arrow(Vector3f.UNIT_Z));
+        arrow.setMaterial(debugMaterial);
+        gizmo.attachChild(arrow);
+
+        return gizmo;
+    }
+
+    private Spatial createLightProbeGizmo() {
+        Node gizmo = new Node("LightProbeNode");
+
+        Sphere sphere = new Sphere(32, 32, lightProbeScale);
+        Geometry probeGeom = new Geometry(PROBE_GEOMETRY_NAME, sphere);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/reflect.j3md");
+        probeGeom.setMaterial(mat);
+        gizmo.attachChild(probeGeom);
+
+        Geometry probeBounds = BoundingSphereDebug.createDebugSphere(assetManager);
+        probeBounds.setName(PROBE_BOUNDS_NAME);
+        gizmo.attachChild(probeBounds);
+
+        return gizmo;
+    }
+
+    /**
+     * Updates the light gizmos based on the current state of lights in the scene.
+     * This method is called every frame when the state is enabled.
+     *
+     * @param tpf The time per frame.
+     */
     @Override
     public void update(float tpf) {
-        if (!isEnabled()) {
-            return;
-        }
-        updateLights(scene);
+        updateLightGizmos(scene);
         debugNode.updateLogicalState(tpf);
+        cleanUpRemovedLights();
+    }
+
+    /**
+     * Renders the debug gizmos onto the screen.
+     *
+     * @param rm The render manager.
+     */
+    @Override
+    public void render(RenderManager rm) {
         debugNode.updateGeometricState();
-        cleanProbes();
-    }
-
-    public void updateLights(Spatial scene) {
-        for (Light light : scene.getWorldLightList()) {
-            switch (light.getType()) {
-
-                case Probe:
-                    LightProbe probe = (LightProbe) light;
-                    probes.add(probe);
-                    Node n = probeMapping.get(probe);
-                    if (n == null) {
-                        n = new Node("DebugProbe");
-                        n.attachChild(debugGeom.clone(true));
-                        n.attachChild(debugBounds.clone(false));
-                        debugNode.attachChild(n);
-                        probeMapping.put(probe, n);
-                    }
-                    Geometry probeGeom = ((Geometry) n.getChild(0));
-                    Material m = probeGeom.getMaterial();
-                    probeGeom.setLocalScale(probeScale);
-                    if (probe.isReady()) {
-                        m.setTexture("CubeMap", probe.getPrefilteredEnvMap());
-                    }
-                    n.setLocalTranslation(probe.getPosition());
-                    n.getChild(1).setLocalScale(probe.getArea().getRadius());
-                    break;
-                default:
-                    break;
+    }
+
+    /**
+     * Recursively traverses the scene graph to find and update light gizmos.
+     * New gizmos are created for new lights, and existing gizmos are updated.
+     *
+     * @param spatial The current spatial to process for lights.
+     */
+    private void updateLightGizmos(Spatial spatial) {
+        // Add or update gizmos for lights attached to the current spatial
+        for (Light light : spatial.getLocalLightList()) {
+            if (!lightFilter.test(light)) {
+                continue;
+            }
+
+            lightDeque.add(light);
+            Spatial gizmo = lightGizmoMap.get(light);
+
+            if (gizmo == null) {
+                gizmo = createLightGizmo(light);
+                if (gizmo != null) {
+                    debugNode.attachChild(gizmo);
+                    lightGizmoMap.put(light, gizmo);
+                    updateGizmoProperties(light, gizmo); // Set initial properties
+                }
+            } else {
+                updateGizmoProperties(light, gizmo);
             }
         }
-        if (scene instanceof Node) {
-            Node n = (Node)scene;
-            for (Spatial spatial : n.getChildren()) {
-                updateLights(spatial);
+
+        // Recursively call for children if it's a Node
+        if (spatial instanceof Node) {
+            Node node = (Node) spatial;
+            for (Spatial child : node.getChildren()) {
+                updateLightGizmos(child);
             }
         }
     }
 
     /**
-     * Set the scenes for which to render light gizmos.
+     * Creates a new gizmo spatial for a given light based on its type.
      *
-     * @param scene the root of the desired scene (alias created)
+     * @param light The light for which to create a gizmo.
+     * @return A spatial representing the gizmo, or null if the light type is not supported.
      */
-    public void setScene(Spatial scene) {
-        this.scene = scene;
+    private Spatial createLightGizmo(Light light) {
+        switch (light.getType()) {
+            case Probe:
+                return createLightProbeGizmo();
+            case Point:
+                return createPointGizmo();
+            case Directional:
+                return createDirectionalGizmo();
+            case Spot:
+                return createSpotGizmo();
+            default:
+                // Unsupported light type
+                return null;
+        }
     }
 
-    private void cleanProbes() {
-        if (probes.size() != probeMapping.size()) {
-            for (LightProbe probe : probeMapping.keySet()) {
-                if (!probes.contains(probe)) {
-                    garbage.add(probe);
+    /**
+     * Updates the visual properties and position of a light gizmo based on its corresponding light.
+     *
+     * @param light The light whose properties are used for updating the gizmo.
+     * @param gizmo The spatial representing the light gizmo.
+     */
+    private void updateGizmoProperties(Light light, Spatial gizmo) {
+        Node lightNode = (Node) gizmo;
+
+        switch (light.getType()) {
+            case Probe:
+                LightProbe probe = (LightProbe) light;
+                Geometry probeGeom = (Geometry) lightNode.getChild(PROBE_GEOMETRY_NAME);
+                Geometry probeBounds = (Geometry) lightNode.getChild(PROBE_BOUNDS_NAME);
+
+                // Update texture if probe is ready
+                if (probe.isReady()) {
+                    Material mat = probeGeom.getMaterial();
+                    if (mat.getTextureParam("CubeMap") == null) {
+                        mat.setTexture("CubeMap", probe.getPrefilteredEnvMap());
+                    }
                 }
-            }
-            for (LightProbe probe : garbage) {
-                probeMapping.remove(probe);
-            }
-            garbage.clear();
-            probes.clear();
+                probeGeom.setLocalScale(lightProbeScale);
+                probeBounds.setLocalScale(probe.getArea().getRadius());
+                gizmo.setLocalTranslation(probe.getPosition());
+                break;
+
+            case Point:
+                PointLight pl = (PointLight) light;
+                Geometry radius = (Geometry) lightNode.getChild(POINT_LIGHT_RADIUS_NAME);
+                radius.setLocalScale(pl.getRadius());
+                gizmo.setLocalTranslation(pl.getPosition());
+                break;
+
+            case Spot:
+                SpotLight sl = (SpotLight) light;
+                gizmo.setLocalTranslation(sl.getPosition());
+
+                tempRotation.lookAt(sl.getDirection(), Vector3f.UNIT_Y);
+                gizmo.setLocalRotation(tempRotation);
+
+                float spotRange = sl.getSpotRange();
+                float innerAngle = sl.getSpotInnerAngle();
+                float outerAngle = sl.getSpotOuterAngle();
+                float innerRadius = spotRange * FastMath.tan(innerAngle);
+                float outerRadius = spotRange * FastMath.tan(outerAngle);
+
+                lightNode.getChild(SPOT_LIGHT_INNER_RADIUS_NAME).setLocalScale(innerRadius);
+                lightNode.getChild(SPOT_LIGHT_OUTER_RADIUS_NAME).setLocalScale(outerRadius);
+                lightNode.getChild(SPOT_LIGHT_RADIUS_NAME).setLocalTranslation(0, 0, spotRange);
+                lightNode.getChild(LIGHT_DIR_ARROW_NAME).setLocalScale(spotRange);
+                break;
+
+            case Directional:
+                DirectionalLight dl = (DirectionalLight) light;
+                tempRotation.lookAt(dl.getDirection(), Vector3f.UNIT_Y);
+                gizmo.setLocalRotation(tempRotation);
+                break;
+
+            default:
+                // Unsupported light type
+                break;
         }
     }
 
-    @Override
-    public void render(RenderManager rm) {
-        if (!isEnabled()) {
-            return;
+    /**
+     * Cleans up gizmos for lights that have been removed from the scene.
+     */
+    private void cleanUpRemovedLights() {
+
+        Iterator<Map.Entry<Light, Spatial>> iterator = lightGizmoMap.entrySet().iterator();
+
+        while (iterator.hasNext()) {
+            Map.Entry<Light, Spatial> entry = iterator.next();
+            Light light = entry.getKey();
+
+            if (!lightDeque.contains(light)) {
+                Spatial gizmo = entry.getValue();
+                gizmo.removeFromParent();
+                iterator.remove();
+            }
         }
-        rm.renderScene(debugNode, getApplication().getViewPort());
+
+        lightDeque.clear();
     }
 
     /**
-     * returns the scale of the probe's debug sphere
-     * @return the scale factor
+     * Sets the scene for which to render light gizmos.
+     * If no scene is set, it defaults to the first scene in the viewport.
+     *
+     * @param scene The root of the desired scene.
+     */
+    public void setScene(Spatial scene) {
+        this.scene = scene;
+        // Clear existing gizmos when the scene changes to avoid displaying gizmos from the old scene
+        debugNode.detachAllChildren();
+        lightGizmoMap.clear();
+        lightDeque.clear();
+    }
+
+    /**
+     * Returns the current scale of the light probe's debug sphere.
+     *
+     * @return The scale factor.
+     */
+    public float getLightProbeScale() {
+        return lightProbeScale;
+    }
+
+    /**
+     * Sets the scale of the light probe's debug sphere.
+     *
+     * @param scale The scale factor (default is 1.0).
+     */
+    public void setLightProbeScale(float scale) {
+        this.lightProbeScale = scale;
+    }
+
+    /**
+     * Checks if the light debug gizmos are set to always
+     * render on top of other scene geometry.
+     *
+     * @return true if gizmos always render on top, false otherwise.
+     */
+    public boolean isShowOnTop() {
+        return showOnTop;
+    }
+
+    /**
+     * Sets whether light debug gizmos should always
+     * render on top of other scene geometry.
+     *
+     * @param showOnTop true to always show gizmos on top, false to respect depth.
      */
-    public float getProbeScale() {
-        return probeScale;
+    public void setShowOnTop(boolean showOnTop) {
+        this.showOnTop = showOnTop;
+        if (viewPort != null) {
+            viewPort.setClearDepth(showOnTop);
+        }
     }
 
     /**
-     * sets the scale of the probe's debug sphere
+     * Sets a filter to control which lights are displayed by the debug state.
+     * By default, no filter is applied, meaning all lights are displayed.
      *
-     * @param probeScale the scale factor (default=1)
+     * @param lightFilter A {@link Predicate} that tests a {@link Light} object.
      */
-    public void setProbeScale(float probeScale) {
-        this.probeScale = probeScale;
+    public void setLightFilter(Predicate<Light> lightFilter) {
+        this.lightFilter = lightFilter;
     }
 
+    /**
+     * Cleans up resources when the app state is detached.
+     *
+     * @param app The application instance.
+     */
     @Override
     protected void cleanup(Application app) {
+        debugNode.detachAllChildren();
+        lightGizmoMap.clear();
+        lightDeque.clear();
+        debugMaterial = null;
+        app.getRenderManager().removeMainView(viewPort);
     }
 
     @Override
     protected void onEnable() {
+        viewPort.attachScene(debugNode);
     }
 
     @Override
     protected void onDisable() {
+        viewPort.detachScene(debugNode);
     }
+
 }

+ 136 - 123
jme3-core/src/main/java/com/jme3/input/FlyByCamera.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
@@ -44,10 +44,10 @@ import com.jme3.renderer.Camera;
 
 /**
  * A first-person camera controller.
- *
+ * <p>
  * After creation, you (or FlyCamAppState) must register the controller using
  * {@link #registerWithInput(com.jme3.input.InputManager)}.
- *
+ * <p>
  * Controls:
  *  - Move (or, in drag-to-rotate mode, drag) the mouse to rotate the camera
  *  - Mouse wheel for zooming in or out
@@ -57,24 +57,24 @@ import com.jme3.renderer.Camera;
 public class FlyByCamera implements AnalogListener, ActionListener {
 
     private static final String[] mappings = new String[]{
-        CameraInput.FLYCAM_LEFT,
-        CameraInput.FLYCAM_RIGHT,
-        CameraInput.FLYCAM_UP,
-        CameraInput.FLYCAM_DOWN,
+            CameraInput.FLYCAM_LEFT,
+            CameraInput.FLYCAM_RIGHT,
+            CameraInput.FLYCAM_UP,
+            CameraInput.FLYCAM_DOWN,
 
-        CameraInput.FLYCAM_STRAFELEFT,
-        CameraInput.FLYCAM_STRAFERIGHT,
-        CameraInput.FLYCAM_FORWARD,
-        CameraInput.FLYCAM_BACKWARD,
+            CameraInput.FLYCAM_STRAFELEFT,
+            CameraInput.FLYCAM_STRAFERIGHT,
+            CameraInput.FLYCAM_FORWARD,
+            CameraInput.FLYCAM_BACKWARD,
 
-        CameraInput.FLYCAM_ZOOMIN,
-        CameraInput.FLYCAM_ZOOMOUT,
-        CameraInput.FLYCAM_ROTATEDRAG,
+            CameraInput.FLYCAM_ZOOMIN,
+            CameraInput.FLYCAM_ZOOMOUT,
+            CameraInput.FLYCAM_ROTATEDRAG,
 
-        CameraInput.FLYCAM_RISE,
-        CameraInput.FLYCAM_LOWER,
+            CameraInput.FLYCAM_RISE,
+            CameraInput.FLYCAM_LOWER,
 
-        CameraInput.FLYCAM_INVERTY
+            CameraInput.FLYCAM_INVERTY
     };
     /**
      * camera controlled by this controller (not null)
@@ -83,7 +83,7 @@ public class FlyByCamera implements AnalogListener, ActionListener {
     /**
      * normalized "up" direction (a unit vector)
      */
-    protected Vector3f initialUpVec;
+    protected Vector3f initialUpVec = new Vector3f();
     /**
      * rotation-rate multiplier (1=default)
      */
@@ -109,6 +109,15 @@ public class FlyByCamera implements AnalogListener, ActionListener {
     protected boolean invertY = false;
     protected InputManager inputManager;
 
+    // Reusable temporary objects to reduce allocations during updates
+    private final Matrix3f tempMat = new Matrix3f();
+    private final Quaternion tempQuat = new Quaternion();
+    private final Vector3f tempUp = new Vector3f();
+    private final Vector3f tempLeft = new Vector3f();
+    private final Vector3f tempDir = new Vector3f();
+    private final Vector3f tempVel = new Vector3f();
+    private final Vector3f tempPos = new Vector3f();
+
     /**
      * Creates a new FlyByCamera to control the specified camera.
      *
@@ -116,7 +125,7 @@ public class FlyByCamera implements AnalogListener, ActionListener {
      */
     public FlyByCamera(Camera cam) {
         this.cam = cam;
-        initialUpVec = cam.getUp().clone();
+        cam.getUp(initialUpVec);
     }
 
     /**
@@ -128,63 +137,61 @@ public class FlyByCamera implements AnalogListener, ActionListener {
         initialUpVec.set(upVec);
     }
 
-    public void setMotionAllowedListener(MotionAllowedListener listener){
+    public void setMotionAllowedListener(MotionAllowedListener listener) {
         this.motionAllowed = listener;
     }
 
     /**
-     * Set the translation speed.
+     * Sets the translation speed of the camera.
      *
-     * @param moveSpeed new speed (in world units per second)
+     * @param moveSpeed The new translation speed in world units per second. Must be non-negative.
      */
-    public void setMoveSpeed(float moveSpeed){
+    public void setMoveSpeed(float moveSpeed) {
         this.moveSpeed = moveSpeed;
     }
 
     /**
-     * Read the translation speed.
+     * Retrieves the current translation speed of the camera.
      *
-     * @return current speed (in world units per second)
+     * @return The current speed in world units per second.
      */
-    public float getMoveSpeed(){
+    public float getMoveSpeed() {
         return moveSpeed;
     }
 
     /**
-     * Set the rotation-rate multiplier. The bigger the multiplier, the more
-     * rotation for a given movement of the mouse.
+     * Sets the rotation-rate multiplier for mouse input. A higher value
+     * means the camera rotates more for a given mouse movement.
      *
-     * @param rotationSpeed new rate multiplier (1=default)
+     * @param rotationSpeed The new rate multiplier (1.0 is default). Must be non-negative.
      */
-    public void setRotationSpeed(float rotationSpeed){
+    public void setRotationSpeed(float rotationSpeed) {
         this.rotationSpeed = rotationSpeed;
     }
 
     /**
-     * Read the rotation-rate multiplier. The bigger the multiplier, the more
-     * rotation for a given movement of the mouse.
+     * Retrieves the current rotation-rate multiplier.
      *
-     * @return current rate multiplier (1=default)
+     * @return The current rate multiplier.
      */
-    public float getRotationSpeed(){
+    public float getRotationSpeed() {
         return rotationSpeed;
     }
 
     /**
-     * Set the zoom-rate multiplier. The bigger the multiplier, the more zoom
-     * for a given movement of the mouse wheel.
+     * Sets the zoom-rate multiplier for mouse wheel input. A higher value
+     * means the camera zooms more for a given mouse wheel scroll.
      *
-     * @param zoomSpeed new rate multiplier (1=default)
+     * @param zoomSpeed The new rate multiplier (1.0 is default). Must be non-negative.
      */
     public void setZoomSpeed(float zoomSpeed) {
         this.zoomSpeed = zoomSpeed;
     }
 
     /**
-     * Read the zoom-rate multiplier. The bigger the multiplier, the more zoom
-     * for a given movement of the mouse wheel.
+     * Retrieves the current zoom-rate multiplier.
      *
-     * @return current rate multiplier (1=default)
+     * @return The current rate multiplier.
      */
     public float getZoomSpeed() {
         return zoomSpeed;
@@ -196,9 +203,9 @@ public class FlyByCamera implements AnalogListener, ActionListener {
      *
      * @param enable true to enable, false to disable
      */
-    public void setEnabled(boolean enable){
-        if (enabled && !enable){
-            if (inputManager!= null && (!dragToRotate || (dragToRotate && canRotate))){
+    public void setEnabled(boolean enable) {
+        if (enabled && !enable) {
+            if (inputManager != null && (!dragToRotate || (dragToRotate && canRotate))) {
                 inputManager.setCursorVisible(true);
             }
         }
@@ -206,20 +213,19 @@ public class FlyByCamera implements AnalogListener, ActionListener {
     }
 
     /**
-     * Test whether this controller is enabled.
+     * Checks whether this camera controller is currently enabled.
      *
-     * @return true if enabled, otherwise false
+     * @return {@code true} if enabled, {@code false} otherwise.
      * @see #setEnabled(boolean)
      */
-    public boolean isEnabled(){
+    public boolean isEnabled() {
         return enabled;
     }
 
     /**
-     * Test whether drag-to-rotate mode is enabled.
-     *
-     * @return If drag to rotate feature is enabled.
+     * Checks whether drag-to-rotate mode is currently enabled.
      *
+     * @return {@code true} if drag-to-rotate is enabled, {@code false} otherwise.
      * @see #setDragToRotate(boolean)
      */
     public boolean isDragToRotate() {
@@ -245,15 +251,16 @@ public class FlyByCamera implements AnalogListener, ActionListener {
     }
 
     /**
-     * Register this controller to receive input events from the specified input
-     * manager.
+     * Registers this controller to receive input events from the specified
+     * {@link InputManager}. This method sets up all the necessary input mappings
+     * for mouse, keyboard, and joysticks.
      *
-     * @param inputManager (not null, alias created)
+     * @param inputManager The InputManager instance to register with (must not be null).
      */
-    public void registerWithInput(InputManager inputManager){
+    public void registerWithInput(InputManager inputManager) {
         this.inputManager = inputManager;
 
-        // both mouse and button - rotation of cam
+        // Mouse and Keyboard Mappings for Rotation
         inputManager.addMapping(CameraInput.FLYCAM_LEFT, new MouseAxisTrigger(MouseInput.AXIS_X, true),
                 new KeyTrigger(KeyInput.KEY_LEFT));
 
@@ -266,7 +273,7 @@ public class FlyByCamera implements AnalogListener, ActionListener {
         inputManager.addMapping(CameraInput.FLYCAM_DOWN, new MouseAxisTrigger(MouseInput.AXIS_Y, true),
                 new KeyTrigger(KeyInput.KEY_DOWN));
 
-        // mouse only - zoom in/out with wheel, and rotate drag
+        // Mouse Mappings for Zoom and Drag-to-Rotate
         inputManager.addMapping(CameraInput.FLYCAM_ZOOMIN, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, false));
         inputManager.addMapping(CameraInput.FLYCAM_ZOOMOUT, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, true));
         inputManager.addMapping(CameraInput.FLYCAM_ROTATEDRAG, new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
@@ -283,13 +290,19 @@ public class FlyByCamera implements AnalogListener, ActionListener {
         inputManager.setCursorVisible(dragToRotate || !isEnabled());
 
         Joystick[] joysticks = inputManager.getJoysticks();
-        if (joysticks != null && joysticks.length > 0){
+        if (joysticks != null && joysticks.length > 0) {
             for (Joystick j : joysticks) {
                 mapJoystick(j);
             }
         }
     }
 
+    /**
+     * Configures joystick input mappings for the camera controller. This method
+     * attempts to map joystick axes and buttons to camera actions.
+     *
+     * @param joystick The {@link Joystick} to map (not null).
+     */
     protected void mapJoystick(Joystick joystick) {
         // Map it differently if there are Z axis
         if (joystick.getAxis(JoystickAxis.Z_ROTATION) != null
@@ -308,7 +321,7 @@ public class FlyByCamera implements AnalogListener, ActionListener {
             // And let the dpad be up and down
             joystick.getPovYAxis().assignAxis(CameraInput.FLYCAM_RISE, CameraInput.FLYCAM_LOWER);
 
-            if( joystick.getButton("Button 8") != null) {
+            if (joystick.getButton("Button 8") != null) {
                 // Let the standard select button be the y invert toggle
                 joystick.getButton("Button 8").assignButton(CameraInput.FLYCAM_INVERTY);
             }
@@ -322,7 +335,7 @@ public class FlyByCamera implements AnalogListener, ActionListener {
     }
 
     /**
-     * Unregister this controller from its input manager.
+     * Unregisters this controller from its currently associated {@link InputManager}.
      */
     public void unregisterInput() {
         if (inputManager == null) {
@@ -338,112 +351,112 @@ public class FlyByCamera implements AnalogListener, ActionListener {
         inputManager.removeListener(this);
         inputManager.setCursorVisible(!dragToRotate);
 
-        Joystick[] joysticks = inputManager.getJoysticks();
-        if (joysticks != null && joysticks.length > 0) {
-            // No way to unassign axis
-        }
+        // Joysticks cannot be "unassigned" in the same way, but mappings are removed with listener.
+        // Joystick-specific mapping might persist but won't trigger this listener.
+        inputManager = null; // Clear reference
     }
 
     /**
-     * Rotate the camera by the specified amount around the specified axis.
+     * Rotates the camera by the specified amount around the given axis.
      *
-     * @param value rotation amount
-     * @param axis direction of rotation (a unit vector)
+     * @param value The amount of rotation.
+     * @param axis  The axis around which to rotate (a unit vector, unaffected).
      */
     protected void rotateCamera(float value, Vector3f axis) {
-        if (dragToRotate) {
-            if (canRotate) {
-//                value = -value;
-            } else {
-                return;
-            }
+        if (dragToRotate && !canRotate) {
+            return; // In drag-to-rotate mode, only rotate if canRotate is true.
         }
 
-        Matrix3f mat = new Matrix3f();
-        mat.fromAngleNormalAxis(rotationSpeed * value, axis);
+        tempMat.fromAngleNormalAxis(rotationSpeed * value, axis);
 
-        Vector3f up = cam.getUp();
-        Vector3f left = cam.getLeft();
-        Vector3f dir = cam.getDirection();
+        // Get current camera axes into temporary vectors
+        cam.getUp(tempUp);
+        cam.getLeft(tempLeft);
+        cam.getDirection(tempDir);
 
-        mat.mult(up, up);
-        mat.mult(left, left);
-        mat.mult(dir, dir);
+        // Apply rotation to the camera's axes
+        tempMat.mult(tempUp, tempUp);
+        tempMat.mult(tempLeft, tempLeft);
+        tempMat.mult(tempDir, tempDir);
 
-        Quaternion q = new Quaternion();
-        q.fromAxes(left, up, dir);
-        q.normalizeLocal();
+        // Set camera axes using a temporary Quaternion
+        tempQuat.fromAxes(tempLeft, tempUp, tempDir);
+        tempQuat.normalizeLocal(); // Ensure quaternion is normalized
 
-        cam.setAxes(q);
+        cam.setAxes(tempQuat);
     }
 
     /**
-     * Zoom the camera by the specified amount.
+     * Zooms the camera by the specified amount. This method handles both
+     * perspective and parallel projections.
      *
-     * @param value zoom amount
+     * @param value The amount to zoom. Positive values typically zoom in, negative out.
      */
     protected void zoomCamera(float value) {
         if (cam.isParallelProjection()) {
             float zoomFactor = 1.0F + value * 0.01F * zoomSpeed;
             if (zoomFactor > 0F) {
-                float left = zoomFactor * cam.getFrustumLeft();
-                float right = zoomFactor * cam.getFrustumRight();
-                float top = zoomFactor * cam.getFrustumTop();
+                float left   = zoomFactor * cam.getFrustumLeft();
+                float right  = zoomFactor * cam.getFrustumRight();
+                float top    = zoomFactor * cam.getFrustumTop();
                 float bottom = zoomFactor * cam.getFrustumBottom();
-
-                float near = cam.getFrustumNear();
-                float far = cam.getFrustumFar();
+                float near   = cam.getFrustumNear();
+                float far    = cam.getFrustumFar();
                 cam.setFrustum(near, far, left, right, top, bottom);
             }
 
         } else { // perspective projection
             float newFov = cam.getFov() + value * 0.1F * zoomSpeed;
-            if (newFov > 0) {
+            // Use a small epsilon to prevent near-zero FoV issues
+            if (newFov > 0.01f) {
                 cam.setFov(newFov);
             }
         }
     }
 
     /**
-     * Translate the camera upward by the specified amount.
+     * Translates the camera vertically (up or down) by the specified amount,
+     * considering the {@code initialUpVec}.
      *
-     * @param value translation amount
+     * @param value The translation amount. Positive values move the camera up, negative down.
      */
     protected void riseCamera(float value) {
-        Vector3f vel = initialUpVec.mult(value * moveSpeed);
-        Vector3f pos = cam.getLocation().clone();
+        tempVel.set(initialUpVec).multLocal(value * moveSpeed);
+        tempPos.set(cam.getLocation());
 
-        if (motionAllowed != null)
-            motionAllowed.checkMotionAllowed(pos, vel);
-        else
-            pos.addLocal(vel);
+        if (motionAllowed != null) {
+            motionAllowed.checkMotionAllowed(tempPos.clone(), tempVel.clone());
+        } else {
+            tempPos.addLocal(tempVel);
+        }
 
-        cam.setLocation(pos);
+        cam.setLocation(tempPos);
     }
 
     /**
-     * Translate the camera left or forward by the specified amount.
+     * Translates the camera left/right or forward/backward by the specified amount.
      *
-     * @param value translation amount
-     * @param sideways true&rarr;left, false&rarr;forward
+     * @param value    The translation amount. Positive values move in the primary
+     *                 direction (right/forward), negative in the opposite.
+     * @param sideways If {@code true}, the camera moves left/right (strafes).
+     *                 If {@code false}, the camera moves forward/backward.
      */
     protected void moveCamera(float value, boolean sideways) {
-        Vector3f vel = new Vector3f();
-        Vector3f pos = cam.getLocation().clone();
-
-        if (sideways){
-            cam.getLeft(vel);
+        if (sideways) {
+            cam.getLeft(tempVel);
         } else {
-            cam.getDirection(vel);
+            cam.getDirection(tempVel);
         }
-        vel.multLocal(value * moveSpeed);
+        tempVel.multLocal(value * moveSpeed);
+        tempPos.set(cam.getLocation());
 
-        if (motionAllowed != null)
-            motionAllowed.checkMotionAllowed(pos, vel);
-        else
-            pos.addLocal(vel);
+        if (motionAllowed != null) {
+            motionAllowed.checkMotionAllowed(tempPos.clone(), tempVel.clone());
+        } else {
+            tempPos.addLocal(tempVel);
+        }
 
-        cam.setLocation(pos);
+        cam.setLocation(tempPos);
     }
 
     /**
@@ -463,9 +476,9 @@ public class FlyByCamera implements AnalogListener, ActionListener {
         } else if (name.equals(CameraInput.FLYCAM_RIGHT)) {
             rotateCamera(-value, initialUpVec);
         } else if (name.equals(CameraInput.FLYCAM_UP)) {
-            rotateCamera(-value * (invertY ? -1 : 1), cam.getLeft());
+            rotateCamera(-value * (invertY ? -1 : 1), cam.getLeft(tempLeft));
         } else if (name.equals(CameraInput.FLYCAM_DOWN)) {
-            rotateCamera(value * (invertY ? -1 : 1), cam.getLeft());
+            rotateCamera(value * (invertY ? -1 : 1), cam.getLeft(tempLeft));
         } else if (name.equals(CameraInput.FLYCAM_FORWARD)) {
             moveCamera(value, false);
         } else if (name.equals(CameraInput.FLYCAM_BACKWARD)) {
@@ -489,20 +502,20 @@ public class FlyByCamera implements AnalogListener, ActionListener {
      * Callback to notify this controller of an action input event.
      *
      * @param name name of the input event
-     * @param value true if the action is "pressed", false otherwise
+     * @param isPressed true if the action is "pressed", false otherwise
      * @param tpf time per frame (in seconds)
      */
     @Override
-    public void onAction(String name, boolean value, float tpf) {
+    public void onAction(String name, boolean isPressed, float tpf) {
         if (!enabled)
             return;
 
         if (name.equals(CameraInput.FLYCAM_ROTATEDRAG) && dragToRotate) {
-            canRotate = value;
-            inputManager.setCursorVisible(!value);
+            canRotate = isPressed;
+            inputManager.setCursorVisible(!isPressed);
         } else if (name.equals(CameraInput.FLYCAM_INVERTY)) {
             // Invert the "up" direction.
-            if (!value) {
+            if (!isPressed) {
                 invertY = !invertY;
             }
         }

+ 5 - 1
jme3-core/src/main/java/com/jme3/input/JoystickButton.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -50,6 +50,10 @@ public interface JoystickButton {
     public static final String BUTTON_9 = "9";
     public static final String BUTTON_10 = "10";
     public static final String BUTTON_11 = "11";
+    public static final String BUTTON_12 = "12";
+    public static final String BUTTON_13 = "13";
+    public static final String BUTTON_14 = "14";
+    public static final String BUTTON_15 = "15";
 
     /**
      * Assign the mapping name to receive events from the given button index

+ 10 - 1
jme3-core/src/main/java/com/jme3/light/AmbientLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -83,4 +83,13 @@ public class AmbientLight extends Light {
         return Type.Ambient;
     }
 
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
+
 }

+ 11 - 6
jme3-core/src/main/java/com/jme3/light/DirectionalLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015-2016 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -132,11 +132,6 @@ public class DirectionalLight extends Light {
         return Type.Directional;
     }
 
-    @Override
-    public String toString() {
-        return getClass().getSimpleName() + "[name=" + name + ", direction=" + direction + ", color=" + color + ", enabled=" + enabled + "]";
-    }
-
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
@@ -157,4 +152,14 @@ public class DirectionalLight extends Light {
         l.direction = direction.clone();
         return l;
     }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", direction=" + direction
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
 }

+ 13 - 13
jme3-core/src/main/java/com/jme3/light/LightProbe.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -48,7 +48,7 @@ import java.util.logging.Logger;
 /**
  * A LightProbe is not exactly a light. It holds environment map information used for Image Based Lighting.
  * This is used for indirect lighting in the Physically Based Rendering pipeline.
- *
+ * <p>
  * A light probe has a position in world space. This is the position from where the Environment Map are rendered.
  * There are two environment data structure  held by the LightProbe :
  * - The irradiance spherical harmonics factors (used for indirect diffuse lighting in the PBR pipeline).
@@ -57,9 +57,9 @@ import java.util.logging.Logger;
  * To compute them see
  * {@link com.jme3.environment.LightProbeFactory#makeProbe(com.jme3.environment.EnvironmentCamera, com.jme3.scene.Spatial)}
  * and {@link EnvironmentCamera}.
- *
+ * <p>
  * The light probe has an area of effect centered on its position. It can have a Spherical area or an Oriented Box area
- *
+ * <p>
  * A LightProbe will only be taken into account when it's marked as ready and enabled.
  * A light probe is ready when it has valid environment map data set.
  * Note that you should never call setReady yourself.
@@ -71,7 +71,8 @@ import java.util.logging.Logger;
 public class LightProbe extends Light implements Savable {
 
     private static final Logger logger = Logger.getLogger(LightProbe.class.getName());
-    public static final Matrix4f FALLBACK_MATRIX = new Matrix4f(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1);
+    public static final Matrix4f FALLBACK_MATRIX = new Matrix4f(
+            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1);
 
     private Vector3f[] shCoefficients;
     private TextureCubeMap prefilteredEnvMap;
@@ -149,16 +150,12 @@ public class LightProbe extends Light implements Savable {
      * @return the pre-existing matrix
      */
     public Matrix4f getUniformMatrix(){
-
         Matrix4f mat = area.getUniformMatrix();
-
         // setting the (sp) entry of the matrix
         mat.m33 = nbMipMaps + 1f / area.getRadius();
-
         return mat;
     }
 
-
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
@@ -180,7 +177,7 @@ public class LightProbe extends Light implements Savable {
         position = (Vector3f) ic.readSavable("position", null);
         area = (ProbeArea)ic.readSavable("area", null);
         if(area == null) {
-            // retro compat
+            // retro compatibility
             BoundingSphere bounds = (BoundingSphere) ic.readSavable("bounds", new BoundingSphere(1.0f, Vector3f.ZERO));
             area = new SphereProbeArea(bounds.getCenter(), bounds.getRadius());
         }
@@ -200,7 +197,6 @@ public class LightProbe extends Light implements Savable {
         }
     }
 
-
     /**
      * returns the bounding volume of this LightProbe
      * @return a bounding volume.
@@ -318,8 +314,12 @@ public class LightProbe extends Light implements Savable {
 
     @Override
     public String toString() {
-        return "Light Probe : " + name + " at " + position + " / " + area;
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", position=" + position
+                + ", area=" + area
+                + ", enabled=" + enabled
+                + "]";
     }
 
-
 }

+ 12 - 1
jme3-core/src/main/java/com/jme3/light/PointLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015-2016, 2018 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -247,4 +247,15 @@ public class PointLight extends Light {
         p.position = position.clone();
         return p;
     }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", position=" + position
+                + ", radius=" + radius
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
 }

+ 16 - 5
jme3-core/src/main/java/com/jme3/light/SpotLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -118,8 +118,7 @@ public class SpotLight extends Light {
         setPosition(position);
         setDirection(direction);
     }
-    
-    
+
     /**
      * Creates a SpotLight at the given position, with the given direction,
      * the given range and the given color.
@@ -160,7 +159,6 @@ public class SpotLight extends Light {
         setDirection(direction);
         setSpotRange(range);
     }  
-    
 
     private void computeAngleParameters() {
         float innerCos = FastMath.cos(spotInnerAngle);
@@ -458,5 +456,18 @@ public class SpotLight extends Light {
         s.position = position.clone();
         return s;
     }
-}
 
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", direction=" + direction
+                + ", position=" + position
+                + ", range=" + spotRange
+                + ", innerAngle=" + spotInnerAngle
+                + ", outerAngle=" + spotOuterAngle
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
+}

+ 85 - 63
jme3-core/src/main/java/com/jme3/material/Material.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2024 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -34,12 +34,20 @@ package com.jme3.material;
 import com.jme3.asset.AssetKey;
 import com.jme3.asset.AssetManager;
 import com.jme3.asset.CloneableSmartAsset;
-import com.jme3.export.*;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
 import com.jme3.light.LightList;
 import com.jme3.material.RenderState.BlendMode;
 import com.jme3.material.RenderState.FaceCullMode;
 import com.jme3.material.TechniqueDef.LightMode;
-import com.jme3.math.*;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Matrix4f;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import com.jme3.math.Vector4f;
 import com.jme3.renderer.Caps;
 import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.Renderer;
@@ -56,7 +64,11 @@ import com.jme3.util.ListMap;
 import com.jme3.util.SafeArrayList;
 
 import java.io.IOException;
-import java.util.*;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -77,7 +89,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     public static final int SAVABLE_VERSION = 2;
     private static final Logger logger = Logger.getLogger(Material.class.getName());
 
-    private AssetKey key;
+    private AssetKey<?> key;
     private String name;
     private MaterialDef def;
     private ListMap<String, MatParam> paramValues = new ListMap<>();
@@ -90,15 +102,24 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     private int sortingId = -1;
 
     /**
-     * Track bind ids for textures and buffers
-     * Used internally 
+     * Manages and tracks texture and buffer binding units for rendering.
+     * Used internally by the Material class.
      */
     public static class BindUnits {
+        /** The current texture unit counter. */
         public int textureUnit = 0;
+        /** The current buffer unit counter. */
         public int bufferUnit = 0;
     }
     private BindUnits bindUnits = new BindUnits();
 
+    /**
+     * Constructs a new Material instance based on a provided MaterialDef.
+     * The material's parameters will be initialized with default values from the definition.
+     *
+     * @param def The material definition to use (cannot be null).
+     * @throws IllegalArgumentException if def is null.
+     */
     public Material(MaterialDef def) {
         if (def == null) {
             throw new IllegalArgumentException("Material definition cannot be null");
@@ -113,40 +134,48 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
     }
 
-    public Material(AssetManager contentMan, String defName) {
-        this(contentMan.loadAsset(new AssetKey<MaterialDef>(defName)));
+    /**
+     * Constructs a new Material by loading its MaterialDef from the asset manager.
+     *
+     * @param assetManager The asset manager to load the MaterialDef from.
+     * @param defName      The asset path of the .j3md file.
+     */
+    public Material(AssetManager assetManager, String defName) {
+        this(assetManager.loadAsset(new AssetKey<MaterialDef>(defName)));
     }
 
     /**
-     * Do not use this constructor. Serialization purposes only.
+     * For serialization only. Do not use.
      */
     public Material() {
     }
 
     /**
      * Returns the asset key name of the asset from which this material was loaded.
+     * <p>This value will be null unless this material was loaded from a .j3m file.</p>
      *
-     * <p>This value will be <code>null</code> unless this material was loaded
-     * from a .j3m file.
-     *
-     * @return Asset key name of the j3m file
+     * @return Asset key name of the .j3m file, or null if not loaded from a file.
      */
     public String getAssetName() {
         return key != null ? key.getName() : null;
     }
 
     /**
-     * @return the name of the material (not the same as the asset name), the returned value can be null
+     * Returns the user-defined name of the material.
+     * This name is distinct from the asset name and may be null or not unique.
+     *
+     * @return The name of the material, or null.
      */
     public String getName() {
         return name;
     }
 
     /**
-     * This method sets the name of the material.
+     * Sets the user-defined name of the material.
      * The name is not the same as the asset name.
-     * It can be null and there is no guarantee of its uniqueness.
-     * @param name the name of the material
+     * It can be null, and there is no guarantee of its uniqueness.
+     *
+     * @param name The name of the material.
      */
     public void setName(String name) {
         this.name = name;
@@ -228,7 +257,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     }
 
     /**
-     * Compares two materials and returns true if they are equal.
+     * Compares two materials for content equality.
      * This methods compare definition, parameters, additional render states.
      * Since materials are mutable objects, implementing equals() properly is not possible,
      * hence the name contentEquals().
@@ -405,7 +434,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     }
 
     /**
-     * Get the material definition (j3md file info) that <code>this</code>
+     * Get the material definition (.j3md file info) that <code>this</code>
      * material is implementing.
      *
      * @return the material definition this material implements.
@@ -494,7 +523,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     /**
      * Pass a parameter to the material shader.
      *
-     * @param name the name of the parameter defined in the material definition (j3md)
+     * @param name the name of the parameter defined in the material definition (.j3md)
      * @param type the type of the parameter {@link VarType}
      * @param value the value of the parameter
      */
@@ -506,7 +535,6 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         } else {
             MatParam val = getParam(name);
             if (val == null) {
-                MatParam paramDef = def.getMaterialParam(name);
                 paramValues.put(name, new MatParam(type, name, value));
             } else {
                 val.setValue(value);
@@ -533,7 +561,6 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         setParam(name, p.getVarType(), value);
     }
 
-
     /**
      * Clear a parameter from this material. The parameter must exist
      * @param name the name of the parameter to clear
@@ -569,14 +596,17 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
 
         checkSetParam(type, name);
-        MatParamTexture val = getTextureParam(name);
-        if (val == null) {
-            checkTextureParamColorSpace(name, value);
-            paramValues.put(name, new MatParamTexture(type, name, value, value.getImage() != null ? value.getImage().getColorSpace() : null));
+        MatParamTexture param = getTextureParam(name);
+
+        checkTextureParamColorSpace(name, value);
+        ColorSpace colorSpace = value.getImage() != null ? value.getImage().getColorSpace() : null;
+
+        if (param == null) {
+            param = new MatParamTexture(type, name, value, colorSpace);
+            paramValues.put(name, param);
         } else {
-            checkTextureParamColorSpace(name, value);
-            val.setTextureValue(value);
-            val.setColorSpace(value.getImage() != null ? value.getImage().getColorSpace() : null);
+            param.setTextureValue(value);
+            param.setColorSpace(colorSpace);
         }
 
         if (technique != null) {
@@ -613,8 +643,8 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     /**
      * Pass a texture to the material shader.
      *
-     * @param name the name of the texture defined in the material definition
-     * (j3md) (for example Texture for Lighting.j3md)
+     * @param name  the name of the texture defined in the material definition
+     *              (.j3md) (e.g. Texture for Lighting.j3md)
      * @param value the Texture object previously loaded by the asset manager
      */
     public void setTexture(String name, Texture value) {
@@ -707,7 +737,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
     }
 
     /**
-     * Pass an uniform buffer object to the material shader.
+     * Pass a uniform buffer object to the material shader.
      *
      * @param name  the name of the buffer object defined in the material definition (j3md).
      * @param value the buffer object.
@@ -843,7 +873,6 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
     }
 
-
     private void updateShaderMaterialParameter(Renderer renderer, VarType type, Shader shader, MatParam param, BindUnits unit, boolean override) {
         if (type == VarType.UniformBufferObject || type == VarType.ShaderStorageBufferObject) {
             ShaderBufferBlock bufferBlock = shader.getBufferBlock(param.getPrefixedName());
@@ -862,7 +891,8 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
             unit.bufferUnit++;
         } else {
             Uniform uniform = shader.getUniform(param.getPrefixedName());
-            if (!override && uniform.isSetByCurrentMaterial()) return;
+            if (!override && uniform.isSetByCurrentMaterial())
+                return;
 
             if (type.isTextureType() || type.isImageType()) {
                 try {
@@ -871,9 +901,9 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
                     } else {
                         renderer.setTextureImage(unit.textureUnit, (TextureImage) param.getValue());
                     }
-                } catch (TextureUnitException exception) {
+                } catch (TextureUnitException ex) {
                     int numTexParams = unit.textureUnit + 1;
-                    String message = "Too many texture parameters (" + numTexParams + ") assigned\n to " + toString();
+                    String message = "Too many texture parameters (" + numTexParams + ") assigned\n to " + this.toString();
                     throw new IllegalStateException(message);
                 }
                 uniform.setValue(VarType.Int, unit.textureUnit);
@@ -884,11 +914,8 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
     }
 
-
-
-
-    private BindUnits updateShaderMaterialParameters(Renderer renderer, Shader shader, SafeArrayList<MatParamOverride> worldOverrides,
-            SafeArrayList<MatParamOverride> forcedOverrides) {
+    private BindUnits updateShaderMaterialParameters(Renderer renderer, Shader shader,
+                 SafeArrayList<MatParamOverride> worldOverrides, SafeArrayList<MatParamOverride> forcedOverrides) {
 
         bindUnits.textureUnit = 0;
         bindUnits.bufferUnit = 0;
@@ -901,20 +928,15 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
 
         for (int i = 0; i < paramValues.size(); i++) {
-
             MatParam param = paramValues.getValue(i);
             VarType type = param.getVarType();
-
             updateShaderMaterialParameter(renderer, type, shader, param, bindUnits, false);
         }
 
-        // TODO HACKY HACK remove this when texture unit is handled by the
-        // uniform.
+        // TODO: HACKY HACK remove this when texture unit is handled by the uniform.
         return bindUnits;
     }
 
-
-
     private void updateRenderState(Geometry geometry, RenderManager renderManager, Renderer renderer, TechniqueDef techniqueDef) {
         RenderState finalRenderState;
         if (renderManager.getForcedRenderState() != null) {
@@ -935,8 +957,9 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
 
     /**
      * Returns true if the geometry world scale indicates that normals will be backward.
-     * @param scalar geometry world scale
-     * @return 
+     *
+     * @param scalar The geometry's world scale vector.
+     * @return true if the normals are effectively backward; false otherwise.
      */
     private boolean isNormalsBackward(Vector3f scalar) {
         // count number of negative scalar vector components
@@ -1113,6 +1136,14 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         render(geom, geom.getWorldLightList(), rm);
     }
 
+    @Override
+    public String toString() {
+        return "Material[name=" + name +
+                ", def=" + (def != null ? def.getName() : null) +
+                ", tech=" + (technique != null && technique.getDef() != null ? technique.getDef().getName() : null) +
+                "]";
+    }
+
     @Override
     public void write(JmeExporter ex) throws IOException {
         OutputCapsule oc = ex.getCapsule(this);
@@ -1123,14 +1154,6 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         oc.writeStringSavableMap(paramValues, "parameters", null);
     }
     
-    @Override
-    public String toString() {
-        return "Material[name=" + name + 
-                ", def=" + (def != null ? def.getName() : null) + 
-                ", tech=" + (technique != null && technique.getDef() != null ? technique.getDef().getName() : null) + 
-                "]";
-    }
-
     @Override
     @SuppressWarnings("unchecked")
     public void read(JmeImporter im) throws IOException {
@@ -1144,7 +1167,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         String defName = ic.readString("material_def", null);
         HashMap<String, MatParam> params = (HashMap<String, MatParam>) ic.readStringSavableMap("parameters", null);
 
-        boolean enableVcolor = false;
+        boolean enableVertexColor = false;
         boolean separateTexCoord = false;
         boolean applyDefaultValues = false;
         boolean guessRenderStateApply = false;
@@ -1160,7 +1183,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
             // Enable compatibility with old models
             if (defName.equalsIgnoreCase("Common/MatDefs/Misc/VertexColor.j3md")) {
                 // Using VertexColor, switch to Unshaded and set VertexColor=true
-                enableVcolor = true;
+                enableVertexColor = true;
                 defName = "Common/MatDefs/Misc/Unshaded.j3md";
             } else if (defName.equalsIgnoreCase("Common/MatDefs/Misc/SimpleTextured.j3md")
                     || defName.equalsIgnoreCase("Common/MatDefs/Misc/SolidColor.j3md")) {
@@ -1212,8 +1235,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
         }
 
         if (applyDefaultValues) {
-            // compatability with old versions where default vars were
-            // not available
+            // compatibility with old versions where default vars were not available
             for (MatParam param : def.getMaterialParams()) {
                 if (param.getValue() != null && paramValues.get(param.getName()) == null) {
                     setParam(param.getName(), param.getVarType(), param.getValue());
@@ -1232,7 +1254,7 @@ public class Material implements CloneableSmartAsset, Cloneable, Savable {
             additionalState.applyStencilTest = additionalState.stencilTest;
             additionalState.applyWireFrame = additionalState.wireframe;
         }
-        if (enableVcolor) {
+        if (enableVertexColor) {
             setBoolean("VertexColor", true);
         }
         if (separateTexCoord) {

+ 9 - 5
jme3-core/src/main/java/com/jme3/material/Materials.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -38,13 +38,17 @@ package com.jme3.material;
  */
 public class Materials {
 
-    public static final String UNSHADED = "Common/MatDefs/Misc/Unshaded.j3md";
-    public static final String LIGHTING = "Common/MatDefs/Light/Lighting.j3md";
-    public static final String PBR = "Common/MatDefs/Light/PBRLighting.j3md";
+    public static final String SHOW_NORMALS = "Common/MatDefs/Misc/ShowNormals.j3md";
+    public static final String UNSHADED     = "Common/MatDefs/Misc/Unshaded.j3md";
+    public static final String LIGHTING     = "Common/MatDefs/Light/Lighting.j3md";
+    public static final String PBR          = "Common/MatDefs/Light/PBRLighting.j3md";
+    public static final String PARTICLE     = "Common/MatDefs/Misc/Particle.j3md";
+    public static final String BILLBOARD    = "Common/MatDefs/Misc/Billboard.j3md";
+    public static final String GUI          = "Common/MatDefs/Gui/Gui.j3md";
 
     /**
      * A private constructor to inhibit instantiation of this class.
      */
     private Materials() {
     }
-}
+}

+ 109 - 104
jme3-core/src/main/java/com/jme3/math/ColorRGBA.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -30,18 +30,23 @@
  */
 package com.jme3.math;
 
-import com.jme3.export.*;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
+
 import java.io.IOException;
 
 /**
  * <code>ColorRGBA</code> defines a color made from a collection of red, green
- * and blue values stored in Linear color space. An alpha value determines is
+ * and blue values stored in Linear color space. An alpha value determines its
  * transparency.
  *
  * @author Mark Powell
- * @version $Id: ColorRGBA.java,v 1.29 2007/09/09 18:25:14 irrisor Exp $
  */
 public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable {
+
     static final float GAMMA = 2.2f;
 
     static final long serialVersionUID = 1;
@@ -153,15 +158,15 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * Copy constructor creates a new <code>ColorRGBA</code> object, based on
      * a provided color.
      *
-     * @param rgba The <code>ColorRGBA</code> object to copy.
+     * @param color The <code>ColorRGBA</code> object to copy.
      */
-    public ColorRGBA(ColorRGBA rgba) {
-        this.a = rgba.a;
-        this.r = rgba.r;
-        this.g = rgba.g;
-        this.b = rgba.b;
+    public ColorRGBA(ColorRGBA color) {
+        this.a = color.a;
+        this.r = color.r;
+        this.g = color.g;
+        this.b = color.b;
     }
-    
+
     /**
      * Constructor creates a new <code>ColorRGBA</code> object, based on
      * a provided Vector4f.
@@ -170,12 +175,9 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * values copied to this color's r, g, b, and a values respectively.
      */
     public ColorRGBA(Vector4f vec4) {
-        this.a = vec4.w;
-        this.r = vec4.x;
-        this.g = vec4.y;
-        this.b = vec4.z;
-    }    
-    
+        set(vec4);
+    }
+
     /**
      * Constructor creates a new <code>ColorRGBA</code> object, based on
      * a provided Vector3f, at full opacity with a 1.0 alpha value by default
@@ -185,10 +187,8 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      */
     public ColorRGBA(Vector3f vec3) {
         this.a = 1.0f;
-        this.r = vec3.x;
-        this.g = vec3.y;
-        this.b = vec3.z;
-    }    
+        set(vec3);
+    }
 
     /**
      * <code>set</code> sets the RGBA values of this <code>ColorRGBA</code>.
@@ -214,24 +214,24 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * <code>set</code> sets the values of this <code>ColorRGBA</code> to those
      * set by a parameter color.
      *
-     * @param rgba The color to set this <code>ColorRGBA</code> to.
+     * @param color The color to set this <code>ColorRGBA</code> to.
      * @return this
      */
-    public ColorRGBA set(ColorRGBA rgba) {
-        if (rgba == null) {
+    public ColorRGBA set(ColorRGBA color) {
+        if (color == null) {
             r = 0;
             g = 0;
             b = 0;
             a = 0;
         } else {
-            r = rgba.r;
-            g = rgba.g;
-            b = rgba.b;
-            a = rgba.a;
+            r = color.r;
+            g = color.g;
+            b = color.b;
+            a = color.a;
         }
         return this;
     }
-    
+
     /**
      * <code>set</code> sets the values of this <code>ColorRGBA</code> to those
      * set by a parameter Vector4f.
@@ -254,8 +254,8 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
             a = vec4.w;
         }
         return this;
-    }    
-    
+    }
+
     /**
      * <code>set</code> sets the values of this <code>ColorRGBA</code> to those
      * set by a parameter Vector3f.
@@ -276,7 +276,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
             b = vec3.z;
         }
         return this;
-    }       
+    }
 
     /**
      * Sets the red color to the specified value.
@@ -336,22 +336,18 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return The <code>float</code> array that contains the color components.
      */
     public float[] getColorArray() {
-        return new float[]{r, g, b, a};
+        return getColorArray(null);
     }
 
     /**
      * Stores the current r,g,b,a values into the given array.  The given array must have a
      * length of 4 or greater, or an array index out of bounds exception will be thrown.
      *
-     * @param store The <code>float</code> array to store the values into.
+     * @param store The <code>float</code> array to store the values into. If null, a new array is created.
      * @return The <code>float</code> array after storage.
      */
     public float[] getColorArray(float[] store) {
-        store[0] = r;
-        store[1] = g;
-        store[2] = b;
-        store[3] = a;
-        return store;
+        return toArray(store);
     }
 
     /**
@@ -401,11 +397,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return this ColorRGBA
      */
     public ColorRGBA interpolateLocal(ColorRGBA finalColor, float changeAmount) {
-        this.r = (1 - changeAmount) * this.r + changeAmount * finalColor.r;
-        this.g = (1 - changeAmount) * this.g + changeAmount * finalColor.g;
-        this.b = (1 - changeAmount) * this.b + changeAmount * finalColor.b;
-        this.a = (1 - changeAmount) * this.a + changeAmount * finalColor.a;
-        return this;
+        return interpolateLocal(this, finalColor, changeAmount);
     }
 
     /**
@@ -416,7 +408,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @param beginColor The beginning color (changeAmount=0).
      * @param finalColor The final color to interpolate towards (changeAmount=1).
      * @param changeAmount An amount between 0.0 - 1.0 representing a percentage
-     *  change from beginColor towards finalColor.
+     * change from beginColor towards finalColor.
      * @return this ColorRGBA
      */
     public ColorRGBA interpolateLocal(ColorRGBA beginColor, ColorRGBA finalColor, float changeAmount) {
@@ -434,11 +426,11 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return a random <code>ColorRGBA</code> with an alpha set to 1.
      */
     public static ColorRGBA randomColor() {
-        ColorRGBA rVal = new ColorRGBA(0, 0, 0, 1);
-        rVal.r = FastMath.nextRandomFloat();
-        rVal.g = FastMath.nextRandomFloat();
-        rVal.b = FastMath.nextRandomFloat();
-        return rVal;
+        float r = FastMath.nextRandomFloat();
+        float g = FastMath.nextRandomFloat();
+        float b = FastMath.nextRandomFloat();
+        float a = 1.0f;
+        return new ColorRGBA(r, g, b, a);
     }
 
     /**
@@ -535,19 +527,19 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
     /**
      * Saves this <code>ColorRGBA</code> into the given <code>float</code> array.
      *
-     * @param floats The <code>float</code> array to take this <code>ColorRGBA</code>.
+     * @param store The <code>float</code> array to take this <code>ColorRGBA</code>.
      * If null, a new <code>float[4]</code> is created.
      * @return The array, with r,g,b,a float values in that order.
      */
-    public float[] toArray(float[] floats) {
-        if (floats == null) {
-            floats = new float[4];
+    public float[] toArray(float[] store) {
+        if (store == null) {
+            store = new float[4];
         }
-        floats[0] = r;
-        floats[1] = g;
-        floats[2] = b;
-        floats[3] = a;
-        return floats;
+        store[0] = r;
+        store[1] = g;
+        store[2] = b;
+        store[3] = a;
+        return store;
     }
 
     /**
@@ -605,32 +597,32 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * Serialize this color to the specified exporter, for example when
      * saving to a J3O file.
      *
-     * @param e (not null)
+     * @param ex (not null)
      * @throws IOException from the exporter
      */
     @Override
-    public void write(JmeExporter e) throws IOException {
-        OutputCapsule capsule = e.getCapsule(this);
-        capsule.write(r, "r", 0);
-        capsule.write(g, "g", 0);
-        capsule.write(b, "b", 0);
-        capsule.write(a, "a", 0);
+    public void write(JmeExporter ex) throws IOException {
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(r, "r", 0);
+        oc.write(g, "g", 0);
+        oc.write(b, "b", 0);
+        oc.write(a, "a", 0);
     }
 
     /**
      * De-serialize this color from the specified importer, for example when
      * loading from a J3O file.
      *
-     * @param importer (not null)
+     * @param im (not null)
      * @throws IOException from the importer
      */
     @Override
-    public void read(JmeImporter importer) throws IOException {
-        InputCapsule capsule = importer.getCapsule(this);
-        r = capsule.readFloat("r", 0);
-        g = capsule.readFloat("g", 0);
-        b = capsule.readFloat("b", 0);
-        a = capsule.readFloat("a", 0);
+    public void read(JmeImporter im) throws IOException {
+        InputCapsule ic = im.getCapsule(this);
+        r = ic.readFloat("r", 0);
+        g = ic.readFloat("g", 0);
+        b = ic.readFloat("b", 0);
+        a = ic.readFloat("a", 0);
     }
 
     /**
@@ -641,10 +633,10 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      */
     public byte[] asBytesRGBA() {
         byte[] store = new byte[4];
-        store[0] = (byte) ((int) (r * 255) & 0xFF);
-        store[1] = (byte) ((int) (g * 255) & 0xFF);
-        store[2] = (byte) ((int) (b * 255) & 0xFF);
-        store[3] = (byte) ((int) (a * 255) & 0xFF);
+        store[0] = toByte(r);
+        store[1] = toByte(g);
+        store[2] = toByte(b);
+        store[3] = toByte(a);
         return store;
     }
 
@@ -656,11 +648,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return The integer representation of this <code>ColorRGBA</code> in a,r,g,b order.
      */
     public int asIntARGB() {
-        int argb = (((int) (a * 255) & 0xFF) << 24)
-                | (((int) (r * 255) & 0xFF) << 16)
-                | (((int) (g * 255) & 0xFF) << 8)
-                | (((int) (b * 255) & 0xFF));
-        return argb;
+        return toInt(a, r, g, b);
     }
 
     /**
@@ -671,11 +659,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return The integer representation of this <code>ColorRGBA</code> in r,g,b,a order.
      */
     public int asIntRGBA() {
-        int rgba = (((int) (r * 255) & 0xFF) << 24)
-                | (((int) (g * 255) & 0xFF) << 16)
-                | (((int) (b * 255) & 0xFF) << 8)
-                | (((int) (a * 255) & 0xFF));
-        return rgba;
+        return toInt(r, g, b, a);
     }
 
     /**
@@ -686,11 +670,7 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return The integer representation of this <code>ColorRGBA</code> in a,b,g,r order.
      */
     public int asIntABGR() {
-        int abgr = (((int) (a * 255) & 0xFF) << 24)
-                | (((int) (b * 255) & 0xFF) << 16)
-                | (((int) (g * 255) & 0xFF) << 8)
-                | (((int) (r * 255) & 0xFF));
-        return abgr;
+        return toInt(a, b, g, r);
     }
 
     /**
@@ -702,10 +682,10 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return this
      */
     public ColorRGBA fromIntARGB(int color) {
-        a = ((byte) (color >> 24) & 0xFF) / 255f;
-        r = ((byte) (color >> 16) & 0xFF) / 255f;
-        g = ((byte) (color >> 8) & 0xFF) / 255f;
-        b = ((byte) (color) & 0xFF) / 255f;
+        a = fromByte(color >> 24);
+        r = fromByte(color >> 16);
+        g = fromByte(color >> 8);
+        b = fromByte(color);
         return this;
     }
 
@@ -717,10 +697,10 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return this
      */
     public ColorRGBA fromIntRGBA(int color) {
-        r = ((byte) (color >> 24) & 0xFF) / 255f;
-        g = ((byte) (color >> 16) & 0xFF) / 255f;
-        b = ((byte) (color >> 8) & 0xFF) / 255f;
-        a = ((byte) (color) & 0xFF) / 255f;
+        r = fromByte(color >> 24);
+        g = fromByte(color >> 16);
+        b = fromByte(color >> 8);
+        a = fromByte(color);
         return this;
     }
 
@@ -732,10 +712,10 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
      * @return this
      */
     public ColorRGBA fromIntABGR(int color) {
-        a = ((byte) (color >> 24) & 0xFF) / 255f;
-        b = ((byte) (color >> 16) & 0xFF) / 255f;
-        g = ((byte) (color >> 8) & 0xFF) / 255f;
-        r = ((byte) (color) & 0xFF) / 255f;
+        a = fromByte(color >> 24);
+        b = fromByte(color >> 16);
+        g = fromByte(color >> 8);
+        r = fromByte(color);
         return this;
     }
 
@@ -825,4 +805,29 @@ public final class ColorRGBA implements Savable, Cloneable, java.io.Serializable
         srgb.a = a;
         return srgb;
     }
+
+    /**
+     * Helper method to convert a float (0-1) to a byte (0-255).
+     */
+    private byte toByte(float channel) {
+        return (byte) ((int) (channel * 255) & 0xFF);
+    }
+
+    /**
+     * Helper method to convert an int (shifted byte) to a float (0-1).
+     */
+    private float fromByte(int channelByte) {
+        return ((byte) (channelByte) & 0xFF) / 255f;
+    }
+
+    /**
+     * Helper method to combine four float channels into an int.
+     */
+    private int toInt(float c1, float c2, float c3, float c4) {
+        int r = ((int) (c1 * 255) & 0xFF);
+        int g = ((int) (c2 * 255) & 0xFF);
+        int b = ((int) (c3 * 255) & 0xFF);
+        int a = ((int) (c4 * 255) & 0xFF);
+        return (r << 24) | (g << 16) | (b << 8) | a;
+    }
 }

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

@@ -501,7 +501,7 @@ public class FilterPostProcessor implements SceneProcessor, Savable {
             }
         }
 
-        if (numSamples <= 1 || !caps.contains(Caps.OpenGL32)) {
+        if (numSamples <= 1 || !caps.contains(Caps.OpenGL32) || !caps.contains(Caps.FrameBufferMultisample)) {
             renderFrameBuffer = new FrameBuffer(width, height, 1);
             renderFrameBuffer.setDepthTarget(FrameBufferTarget.newTarget(depthFormat));
             filterTexture = new Texture2D(width, height, fbFormat);

+ 3 - 1
jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java

@@ -516,7 +516,9 @@ public final class GLRenderer implements Renderer {
                 limits.put(Limits.FrameBufferSamples, getInteger(GLExt.GL_MAX_SAMPLES_EXT));
             }
 
-            if (hasExtension("GL_ARB_texture_multisample") || caps.contains(Caps.OpenGLES31)) { // GLES31 does not fully support it
+            if (hasExtension("GL_ARB_texture_multisample") || caps.contains(Caps.OpenGLES31)
+                    || (JmeSystem.getPlatform().getOs() == Platform.Os.MacOS
+                            && caps.contains(Caps.OpenGL32))) { // GLES31 does not fully support it
                 caps.add(Caps.TextureMultisample);
                 limits.put(Limits.ColorTextureSamples, getInteger(GLExt.GL_MAX_COLOR_TEXTURE_SAMPLES));
                 limits.put(Limits.DepthTextureSamples, getInteger(GLExt.GL_MAX_DEPTH_TEXTURE_SAMPLES));

+ 22 - 5
jme3-core/src/main/java/com/jme3/scene/VertexBuffer.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
@@ -57,7 +57,7 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
     /**
      * Type of buffer. Specifies the actual attribute it defines.
      */
-    public static enum Type {
+    public enum Type {
         /**
          * Position of the vertex (3 floats)
          */
@@ -233,7 +233,7 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
      * is used. This can determine if a vertex buffer is placed in VRAM
      * or held in video memory, but no guarantees are made- it's only a hint.
      */
-    public static enum Usage {
+    public enum Usage {
         /**
          * Mesh data is sent once and very rarely updated.
          */
@@ -261,7 +261,7 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
      * For the {@link Format#Half} type, {@link ByteBuffer}s should
      * be used.
      */
-    public static enum Format {
+    public enum Format {
         /**
          * Half precision floating point. 2 bytes, signed.
          */
@@ -1130,6 +1130,7 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
         oc.write(offset, "offset", 0);
         oc.write(stride, "stride", 0);
         oc.write(instanceSpan, "instanceSpan", 0);
+        oc.write(name, "name", null);
 
         String dataName = "data" + format.name();
         Buffer roData = getDataReadOnly();
@@ -1166,6 +1167,8 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
         offset = ic.readInt("offset", 0);
         stride = ic.readInt("stride", 0);
         instanceSpan = ic.readInt("instanceSpan", 0);
+        name = ic.readString("name", null);
+
         componentsLength = components * format.getComponentSize();
 
         String dataName = "data" + format.name();
@@ -1191,14 +1194,28 @@ public class VertexBuffer extends NativeObject implements Savable, Cloneable {
         }
     }
 
+    /**
+     * Returns the name of this `VertexBuffer`. If no name has been explicitly
+     * set, a default name is generated based on its class name and buffer type
+     * (e.g., "VertexBuffer(Position)").
+     *
+     * @return The name of the `VertexBuffer`.
+     */
     public String getName() {
         if (name == null) {
-            name = getClass().getSimpleName() + "(" + getBufferType().name() + ")";
+            return String.format("%s(%s)", getClass().getSimpleName(), getBufferType().name());
         }
         return name;
     }
 
+    /**
+     * Sets a custom name for this `VertexBuffer`.
+     *
+     * @param name The new name for the `VertexBuffer`. Can be null to revert
+     * to the default generated name.
+     */
     public void setName(String name) {
         this.name = name;
     }
+    
 }

+ 157 - 60
jme3-core/src/main/java/com/jme3/scene/control/LightControl.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
@@ -49,52 +49,77 @@ import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 
 /**
- * This Control maintains a reference to a Light,
- * which will be synched with the position (worldTranslation)
- * of the current spatial.
+ * `LightControl` synchronizes the world transformation (position and/or
+ * direction) of a `Light` with its attached `Spatial`. This control allows
+ * a light to follow a spatial or vice-versa, depending on the chosen
+ * {@link ControlDirection}.
+ * <p>
+ * This is particularly useful for attaching lights to animated characters,
+ * moving vehicles, or dynamically controlled objects.
+ * </p>
  *
- * @author tim
+ * @author Tim
+ * @author Markil 3
+ * @author capdevon
  */
 public class LightControl extends AbstractControl {
 
-    private static final String CONTROL_DIR_NAME = "controlDir";
-    private static final String LIGHT_NAME = "light";
-
+    /**
+     * Defines the direction of synchronization between the light and the spatial.
+     */
     public enum ControlDirection {
-
         /**
-         * Means, that the Light's transform is "copied"
-         * to the Transform of the Spatial.
+         * The light's transform is copied to the spatial's transform.
          */
         LightToSpatial,
         /**
-         * Means, that the Spatial's transform is "copied"
-         * to the Transform of the light.
+         * The spatial's transform is copied to the light's transform.
          */
         SpatialToLight
     }
 
+    /**
+     * Represents the local axis of the spatial (X, Y, or Z) to be used
+     * for determining the light's direction when `ControlDirection` is
+     * `SpatialToLight`.
+     */
+    public enum Axis {
+        X, Y, Z
+    }
+
     private Light light;
     private ControlDirection controlDir = ControlDirection.SpatialToLight;
+    private Axis axisRotation = Axis.Z;
+    private boolean invertAxisDirection = false;
 
     /**
-     * Constructor used for Serialization.
+     * For serialization only. Do not use.
      */
     public LightControl() {
     }
 
     /**
+     * Creates a new `LightControl` that synchronizes the light's transform to the spatial.
+     *
      * @param light The light to be synced.
+     * @throws IllegalArgumentException if the light type is not supported
+     * (only Point, Directional, and Spot lights are supported).
      */
     public LightControl(Light light) {
+        validateSupportedLightType(light);
         this.light = light;
     }
 
     /**
+     * Creates a new `LightControl` with a specified synchronization direction.
+     *
      * @param light The light to be synced.
-     * @param controlDir SpatialToLight or LightToSpatial
+     * @param controlDir The direction of synchronization (SpatialToLight or LightToSpatial).
+     * @throws IllegalArgumentException if the light type is not supported
+     * (only Point, Directional, and Spot lights are supported).
      */
     public LightControl(Light light, ControlDirection controlDir) {
+        validateSupportedLightType(light);
         this.light = light;
         this.controlDir = controlDir;
     }
@@ -104,6 +129,7 @@ public class LightControl extends AbstractControl {
     }
 
     public void setLight(Light light) {
+        validateSupportedLightType(light);
         this.light = light;
     }
 
@@ -115,86 +141,141 @@ public class LightControl extends AbstractControl {
         this.controlDir = controlDir;
     }
 
-    // fields used when inverting ControlDirection:
+    public Axis getAxisRotation() {
+        return axisRotation;
+    }
+
+    public void setAxisRotation(Axis axisRotation) {
+        this.axisRotation = axisRotation;
+    }
+
+    public boolean isInvertAxisDirection() {
+        return invertAxisDirection;
+    }
+
+    public void setInvertAxisDirection(boolean invertAxisDirection) {
+        this.invertAxisDirection = invertAxisDirection;
+    }
+
+    private void validateSupportedLightType(Light light) {
+        if (light == null) {
+            return;
+        }
+
+        switch (light.getType()) {
+            case Point:
+            case Directional:
+            case Spot:
+                // These types are supported, validation passes.
+                break;
+            default:
+                throw new IllegalArgumentException(
+                        "Unsupported Light type: " + light.getType());
+        }
+    }
+
     @Override
     protected void controlUpdate(float tpf) {
-        if (spatial != null && light != null) {
-            switch (controlDir) {
-                case SpatialToLight:
-                    spatialToLight(light);
-                    break;
-                case LightToSpatial:
-                    lightToSpatial(light);
-                    break;
-            }
+        if (light == null) {
+            return;
+        }
+
+        switch (controlDir) {
+            case SpatialToLight:
+                spatialToLight(light);
+                break;
+            case LightToSpatial:
+                lightToSpatial(light);
+                break;
         }
     }
 
     /**
-     * Sets the light to adopt the spatial's world transformations.
+     * Updates the light's position and/or direction to match the spatial's
+     * world transformation.
      *
-     * @author Markil 3
-     * @author pspeed42
+     * @param light The light whose properties will be set.
      */
     private void spatialToLight(Light light) {
         TempVars vars = TempVars.get();
 
-        final Vector3f worldTranslation = vars.vect1;
-        worldTranslation.set(spatial.getWorldTranslation());
-        final Vector3f worldDirection = vars.vect2;
-        spatial.getWorldRotation().mult(Vector3f.UNIT_Z, worldDirection).negateLocal();
+        final Vector3f worldPosition = vars.vect1;
+        worldPosition.set(spatial.getWorldTranslation());
+
+        final Vector3f lightDirection = vars.vect2;
+        spatial.getWorldRotation().getRotationColumn(axisRotation.ordinal(), lightDirection);
+        if (invertAxisDirection) {
+            lightDirection.negateLocal();
+        }
 
         if (light instanceof PointLight) {
-            ((PointLight) light).setPosition(worldTranslation);
+            ((PointLight) light).setPosition(worldPosition);
+
         } else if (light instanceof DirectionalLight) {
-            ((DirectionalLight) light).setDirection(worldDirection);
+            ((DirectionalLight) light).setDirection(lightDirection);
+
         } else if (light instanceof SpotLight) {
-            final SpotLight spotLight = (SpotLight) light;
-            spotLight.setPosition(worldTranslation);
-            spotLight.setDirection(worldDirection);
+            SpotLight sl = (SpotLight) light;
+            sl.setPosition(worldPosition);
+            sl.setDirection(lightDirection);
         }
         vars.release();
     }
 
     /**
-     * Sets the spatial to adopt the light's world transformations.
+     * Updates the spatial's local transformation (position and/or rotation)
+     * to match the light's world transformation.
      *
-     * @author Markil 3
+     * @param light The light from which properties will be read.
      */
     private void lightToSpatial(Light light) {
         TempVars vars = TempVars.get();
-        Vector3f translation = vars.vect1;
-        Vector3f direction = vars.vect2;
+        Vector3f lightPosition = vars.vect1;
+        Vector3f lightDirection = vars.vect2;
         Quaternion rotation = vars.quat1;
-        boolean rotateSpatial = false, translateSpatial = false;
+        boolean rotateSpatial = false;
+        boolean translateSpatial = false;
 
         if (light instanceof PointLight) {
-            PointLight pLight = (PointLight) light;
-            translation.set(pLight.getPosition());
+            PointLight pl = (PointLight) light;
+            lightPosition.set(pl.getPosition());
             translateSpatial = true;
+
         } else if (light instanceof DirectionalLight) {
-            DirectionalLight dLight = (DirectionalLight) light;
-            direction.set(dLight.getDirection()).negateLocal();
+            DirectionalLight dl = (DirectionalLight) light;
+            lightDirection.set(dl.getDirection());
+            if (invertAxisDirection) {
+                lightDirection.negateLocal();
+            }
             rotateSpatial = true;
+
         } else if (light instanceof SpotLight) {
-            SpotLight sLight = (SpotLight) light;
-            translation.set(sLight.getPosition());
-            direction.set(sLight.getDirection()).negateLocal();
-            translateSpatial = rotateSpatial = true;
+            SpotLight sl = (SpotLight) light;
+            lightPosition.set(sl.getPosition());
+            lightDirection.set(sl.getDirection());
+            if (invertAxisDirection) {
+                lightDirection.negateLocal();
+            }
+            translateSpatial = true;
+            rotateSpatial = true;
         }
+
+        // Transform light's world properties to spatial's parent's local space
         if (spatial.getParent() != null) {
+            // Get inverse of parent's world matrix
             spatial.getParent().getLocalToWorldMatrix(vars.tempMat4).invertLocal();
-            vars.tempMat4.rotateVect(translation);
-            vars.tempMat4.translateVect(translation);
-            vars.tempMat4.rotateVect(direction);
+            vars.tempMat4.rotateVect(lightPosition);
+            vars.tempMat4.translateVect(lightPosition);
+            vars.tempMat4.rotateVect(lightDirection);
         }
 
+        // Apply transformed properties to spatial's local transformation
         if (rotateSpatial) {
-            rotation.lookAt(direction, Vector3f.UNIT_Y).normalizeLocal();
+            rotation.lookAt(lightDirection, Vector3f.UNIT_Y).normalizeLocal();
             spatial.setLocalRotation(rotation);
         }
         if (translateSpatial) {
-            spatial.setLocalTranslation(translation);
+            spatial.setLocalTranslation(lightPosition);
         }
         vars.release();
     }
@@ -214,15 +295,31 @@ public class LightControl extends AbstractControl {
     public void read(JmeImporter im) throws IOException {
         super.read(im);
         InputCapsule ic = im.getCapsule(this);
-        controlDir = ic.readEnum(CONTROL_DIR_NAME, ControlDirection.class, ControlDirection.SpatialToLight);
-        light = (Light) ic.readSavable(LIGHT_NAME, null);
+        light = (Light) ic.readSavable("light", null);
+        controlDir = ic.readEnum("controlDir", ControlDirection.class, ControlDirection.SpatialToLight);
+        axisRotation = ic.readEnum("axisRotation", Axis.class, Axis.Z);
+        invertAxisDirection = ic.readBoolean("invertAxisDirection", false);
     }
 
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
         OutputCapsule oc = ex.getCapsule(this);
-        oc.write(controlDir, CONTROL_DIR_NAME, ControlDirection.SpatialToLight);
-        oc.write(light, LIGHT_NAME, null);
+        oc.write(light, "light", null);
+        oc.write(controlDir, "controlDir", ControlDirection.SpatialToLight);
+        oc.write(axisRotation, "axisRotation", Axis.Z);
+        oc.write(invertAxisDirection, "invertAxisDirection", false);
+    }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName() +
+                "[light=" + (light != null ? light.getType() : null) +
+                ", controlDir=" + controlDir +
+                ", axisRotation=" + axisRotation +
+                ", invertAxisDirection=" + invertAxisDirection +
+                ", enabled=" + enabled +
+                ", spatial=" + spatial +
+                "]";
     }
-}
+}

+ 113 - 27
jme3-core/src/main/java/com/jme3/scene/debug/WireFrustum.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
@@ -32,74 +32,160 @@
 package com.jme3.scene.debug;
 
 import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Geometry;
 import com.jme3.scene.Mesh;
 import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.VertexBuffer.Usage;
+import com.jme3.shadow.ShadowUtil;
 import com.jme3.util.BufferUtils;
 import java.nio.FloatBuffer;
 
+/**
+ * A specialized Mesh that renders a camera frustum as a wireframe.
+ * This class extends jME3's Mesh and is designed to visually represent
+ * the viewing volume of a camera, which can be useful for debugging
+ * or visualization purposes.
+ * <p>
+ * The frustum is defined by eight points: four for the near plane
+ * and four for the far plane. These points are connected by lines
+ * to form a wireframe cube-like structure.
+ */
 public class WireFrustum extends Mesh {
 
     /**
-     * This constructor is for serialization only. Do not use.
+     * For Serialization only. Do not use.
      */
     protected WireFrustum() {
     }
 
-    public WireFrustum(Vector3f[] points){
-        initGeom(this, points);
-    }
-
-    public static Mesh makeFrustum(Vector3f[] points){
-        Mesh m = new Mesh();
-        initGeom(m, points);
-        return m;
+    /**
+     * Constructs a new `WireFrustum` mesh using the specified frustum corner points.
+     * The points should represent the 8 corners of the frustum.
+     * The expected order of points is typically:
+     * 0-3: Near plane (e.g., bottom-left, bottom-right, top-right, top-left)
+     * 4-7: Far plane (e.g., bottom-left, bottom-right, top-right, top-left)
+     *
+     * @param points An array of 8 `Vector3f` objects representing the frustum's corners.
+     * If the array is null or does not contain 8 points, an
+     * `IllegalArgumentException` will be thrown.
+     */
+    public WireFrustum(Vector3f[] points) {
+        if (points == null || points.length != 8) {
+            throw new IllegalArgumentException("Frustum points array must not be null and must contain 8 points.");
+        }
+        setGeometryData(points);
     }
 
-    private static void initGeom(Mesh m, Vector3f[] points) {
-        if (points != null)
-            m.setBuffer(Type.Position, 3, BufferUtils.createFloatBuffer(points));
+    /**
+     * Initializes the mesh's geometric data, setting up the vertex positions and indices.
+     * This method is called during the construction of the `WireFrustum`.
+     *
+     * @param points The 8 `Vector3f` points defining the frustum's corners.
+     */
+    private void setGeometryData(Vector3f[] points) {
+        // Set vertex positions
+        setBuffer(Type.Position, 3, BufferUtils.createFloatBuffer(points));
 
-        m.setBuffer(Type.Index, 2,
+        // Set indices to draw lines connecting the frustum corners
+        // The indices define 12 lines: 4 for near plane, 4 for far plane, and 4 connecting near to far.
+        setBuffer(Type.Index, 2,
                 new short[]{
+                        // Near plane
                         0, 1,
                         1, 2,
                         2, 3,
                         3, 0,
 
+                        // Far plane
                         4, 5,
                         5, 6,
                         6, 7,
                         7, 4,
 
+                        // Connecting lines (near to far)
                         0, 4,
                         1, 5,
                         2, 6,
                         3, 7,
                 }
         );
-        m.getBuffer(Type.Index).setUsage(Usage.Static);
-        m.setMode(Mode.Lines);
+        getBuffer(Type.Index).setUsage(Usage.Static);
+        setMode(Mode.Lines);
+        updateBound();
     }
 
-    public void update(Vector3f[] points){
+    /**
+     * Updates the vertex positions of the existing `WireFrustum` mesh.
+     * This is more efficient than creating a new `WireFrustum` instance
+     * if only the frustum's position or orientation changes.
+     *
+     * @param points An array of 8 `Vector3f` objects representing the new frustum's corners.
+     * If the array is null or does not contain 8 points, an
+     * `IllegalArgumentException` will be thrown.
+     */
+    public void update(Vector3f[] points) {
+        if (points == null || points.length != 8) {
+            throw new IllegalArgumentException("Frustum points array must not be null and must contain 8 points.");
+        }
+
         VertexBuffer vb = getBuffer(Type.Position);
-        if (vb == null){
-            setBuffer(Type.Position, 3, BufferUtils.createFloatBuffer(points));
+        if (vb == null) {
+            // If for some reason the position buffer is missing, re-create it.
+            // This case should ideally not happen if the object is constructed properly.
+            setGeometryData(points);
             return;
         }
 
-        FloatBuffer b = BufferUtils.createFloatBuffer(points);
-        FloatBuffer a = (FloatBuffer) vb.getData();
-        b.rewind();
-        a.rewind();
-        a.put(b);
-        a.rewind();
+        // Create a new FloatBuffer from the updated points
+        FloatBuffer newBuff = BufferUtils.createFloatBuffer(points);
+        // Get the existing FloatBuffer from the VertexBuffer
+        FloatBuffer currBuff = (FloatBuffer) vb.getData();
 
-        vb.updateData(a);
-        
+        currBuff.clear();       // Clear
+        currBuff.put(newBuff);  // Copy
+        currBuff.rewind();      // Rewind
+
+        // Update the VertexBuffer with the modified FloatBuffer data
+        vb.updateData(currBuff);
+
+        // Update the mesh's bounding volume to reflect the new vertex positions
         updateBound();
     }
 
+    /**
+     * A static factory method to create a new `WireFrustum` mesh.
+     * This method provides a cleaner way to instantiate a `WireFrustum`.
+     *
+     * @param points An array of 8 `Vector3f` objects representing the frustum's corners.
+     * @return A new `WireFrustum` instance.
+     */
+    public static Mesh makeFrustum(Vector3f[] points) {
+        return new WireFrustum(points);
+    }
+
+    /**
+     * Creates a `Geometry` object representing the wireframe frustum of a given camera.
+     * The frustum points are calculated based on the camera's current view settings.
+     * The returned `Geometry` can be directly attached to a scene graph.
+     *
+     * @param camera The `Camera` whose frustum is to be visualized.
+     * @return A `Geometry` object containing the `WireFrustum` mesh.
+     */
+    public static Geometry makeGeometry(Camera camera) {
+        Vector3f[] frustumCorners = new Vector3f[8];
+        for (int i = 0; i < 8; i++) {
+            frustumCorners[i] = new Vector3f();
+        }
+
+        Camera tempCam = camera.clone();
+        tempCam.setLocation(new Vector3f(0, 0, 0));
+        tempCam.lookAt(Vector3f.UNIT_Z, Vector3f.UNIT_Y);
+        ShadowUtil.updateFrustumPoints2(tempCam, frustumCorners);
+
+        WireFrustum mesh = new WireFrustum(frustumCorners);
+        return new Geometry("Viewing Frustum", mesh);
+    }
+
 }

+ 340 - 112
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,59 +31,115 @@
  */
 package com.jme3.scene.debug.custom;
 
-import com.jme3.anim.*;
+import com.jme3.anim.Armature;
+import com.jme3.anim.Joint;
+import com.jme3.anim.SkinningControl;
 import com.jme3.app.Application;
 import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
 import com.jme3.collision.CollisionResults;
+import com.jme3.input.InputManager;
 import com.jme3.input.KeyInput;
 import com.jme3.input.MouseInput;
-import com.jme3.input.controls.*;
-import com.jme3.light.DirectionalLight;
-import com.jme3.math.*;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.controls.MouseButtonTrigger;
+import com.jme3.math.Ray;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.Camera;
+import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.ViewPort;
-import com.jme3.scene.*;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.SceneGraphVisitorAdapter;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.control.AbstractControl;
+import com.jme3.util.TempVars;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 /**
+ * A debug application state for visualizing and interacting with JME3 armatures (skeletons).
+ * This state allows users to see the joints of an armature, select individual joints
+ * by clicking on them, and view their local and model transforms.
+ * It also provides a toggle to display non-deforming joints.
+ * <p>
+ * This debug state operates on its own `ViewPort` and `debugNode` to prevent
+ * interference with the main scene's rendering.
+ *
  * @author Nehon
+ * @author capdevon
  */
 public class ArmatureDebugAppState extends BaseAppState {
 
+    private static final Logger logger = Logger.getLogger(ArmatureDebugAppState.class.getName());
+
+    private static final String PICK_JOINT = "ArmatureDebugAppState_PickJoint";
+    private static final String TOGGLE_JOINTS = "ArmatureDebugAppState_DisplayAllJoints";
+
+    /**
+     * The maximum delay for a mouse click to be registered as a single click.
+     */
     public static final float CLICK_MAX_DELAY = 0.2f;
-    private Node debugNode = new Node("debugNode");
-    private Map<Armature, ArmatureDebugger> armatures = new HashMap<>();
-    private Map<Armature, Joint> selectedBones = new HashMap<>();
-    private Application app;
-    private boolean displayAllJoints = false;
+
+    private Node debugNode = new Node("ArmaturesDebugNode");
+    private final Map<Armature, ArmatureDebugger> armatures = new HashMap<>();
+    private final List<Consumer<Joint>> selectionListeners = new ArrayList<>();
+    private boolean displayNonDeformingJoints = false;
     private float clickDelay = -1;
-    Vector3f tmp = new Vector3f();
-    Vector3f tmp2 = new Vector3f();
-    ViewPort vp;
+    private ViewPort vp;
+    private Camera cam;
+    private InputManager inputManager;
+    private boolean showOnTop = true;
+    private boolean enableJointInfoLogging = true;
 
     @Override
     protected void initialize(Application app) {
-        vp = app.getRenderManager().createMainView("debug", app.getCamera());
+
+        inputManager = app.getInputManager();
+        cam = app.getCamera();
+
+        vp = app.getRenderManager().createMainView("ArmatureDebugView", cam);
         vp.attachScene(debugNode);
-        vp.setClearDepth(true);
-        this.app = app;
-        for (ArmatureDebugger armatureDebugger : armatures.values()) {
-            armatureDebugger.initialize(app.getAssetManager(), app.getCamera());
-        }
-        app.getInputManager().addListener(actionListener, "shoot", "toggleJoints");
-        app.getInputManager().addMapping("shoot", new MouseButtonTrigger(MouseInput.BUTTON_LEFT), new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
-        app.getInputManager().addMapping("toggleJoints", new KeyTrigger(KeyInput.KEY_F10));
+        vp.setClearDepth(showOnTop);
 
-        debugNode.addLight(new DirectionalLight(new Vector3f(-1f, -1f, -1f).normalizeLocal()));
+        for (ArmatureDebugger debugger : armatures.values()) {
+            debugger.initialize(app.getAssetManager(), cam);
+        }
 
-        debugNode.addLight(new DirectionalLight(new Vector3f(1f, 1f, 1f).normalizeLocal(), new ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f)));
+        // Initially disable the viewport until the state is enabled
         vp.setEnabled(false);
+
+        registerInput();
+    }
+
+    private void registerInput() {
+        inputManager.addMapping(PICK_JOINT, new MouseButtonTrigger(MouseInput.BUTTON_LEFT), new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
+        inputManager.addMapping(TOGGLE_JOINTS, new KeyTrigger(KeyInput.KEY_F10));
+        inputManager.addListener(actionListener, PICK_JOINT, TOGGLE_JOINTS);
+    }
+
+    private void unregisterInput() {
+        inputManager.deleteMapping(PICK_JOINT);
+        inputManager.deleteMapping(TOGGLE_JOINTS);
+        inputManager.removeListener(actionListener);
     }
 
     @Override
     protected void cleanup(Application app) {
-
+        unregisterInput();
+        app.getRenderManager().removeMainView(vp);
+        // Clear maps to release references
+        armatures.clear();
+        selectionListeners.clear();
+        debugNode.detachAllChildren();
     }
 
     @Override
@@ -102,139 +158,311 @@ public class ArmatureDebugAppState extends BaseAppState {
             clickDelay += tpf;
         }
         debugNode.updateLogicalState(tpf);
-        debugNode.updateGeometricState();
+    }
 
+    @Override
+    public void render(RenderManager rm) {
+        debugNode.updateGeometricState();
     }
 
-    public ArmatureDebugger addArmatureFrom(SkinningControl skinningControl) {
-        Armature armature = skinningControl.getArmature();
-        Spatial forSpatial = skinningControl.getSpatial();
-        return addArmatureFrom(armature, forSpatial);
+    /**
+     * Adds an ArmatureDebugger for the armature associated with a given SkinningControl.
+     *
+     * @param skControl The SkinningControl whose armature needs to be debugged.
+     * @return The newly created or existing ArmatureDebugger for the given armature.
+     */
+    public ArmatureDebugger addArmatureFrom(SkinningControl skControl) {
+        return addArmatureFrom(skControl.getArmature(), skControl.getSpatial());
     }
 
-    public ArmatureDebugger addArmatureFrom(Armature armature, Spatial forSpatial) {
+    /**
+     * Adds an ArmatureDebugger for a specific Armature, associating it with a Spatial.
+     * If an ArmatureDebugger for this armature already exists, it is returned.
+     * Otherwise, a new ArmatureDebugger is created, initialized, and attached to the debug node.
+     *
+     * @param armature The Armature to debug.
+     * @param sp The Spatial associated with this armature (used for determining world transform and deforming joints).
+     * @return The newly created or existing ArmatureDebugger for the given armature.
+     */
+    public ArmatureDebugger addArmatureFrom(Armature armature, Spatial sp) {
 
-        ArmatureDebugger ad = armatures.get(armature);
-        if(ad != null){
-            return ad;
+        ArmatureDebugger debugger = armatures.get(armature);
+        if (debugger != null) {
+            return debugger;
         }
 
-        JointInfoVisitor visitor = new JointInfoVisitor(armature);
-        forSpatial.depthFirstTraversal(visitor);
+        // Use a visitor to find joints that actually deform the mesh
+        JointInfoVisitor jointVisitor = new JointInfoVisitor(armature);
+        sp.depthFirstTraversal(jointVisitor);
+
+        Spatial target = sp;
 
-        ad = new ArmatureDebugger(forSpatial.getName() + "_Armature", armature, visitor.deformingJoints);
-        ad.setLocalTransform(forSpatial.getWorldTransform());
-        if (forSpatial instanceof Node) {
+        if (sp instanceof Node) {
             List<Geometry> geoms = new ArrayList<>();
-            findGeoms((Node) forSpatial, geoms);
+            collectGeometries((Node) sp, geoms);
             if (geoms.size() == 1) {
-                ad.setLocalTransform(geoms.get(0).getWorldTransform());
+                target = geoms.get(0);
             }
         }
-        armatures.put(armature, ad);
-        debugNode.attachChild(ad);
+
+        // Create a new ArmatureDebugger
+        debugger = new ArmatureDebugger(sp.getName() + "_ArmatureDebugger", armature, jointVisitor.deformingJoints);
+        debugger.addControl(new ArmatureDebuggerLink(target));
+
+        // Store and attach the new debugger
+        armatures.put(armature, debugger);
+        debugNode.attachChild(debugger);
+
+        // If the AppState is already initialized, initialize the new ArmatureDebugger immediately
         if (isInitialized()) {
-            ad.initialize(app.getAssetManager(), app.getCamera());
+            AssetManager assetManager = getApplication().getAssetManager();
+            debugger.initialize(assetManager, cam);
         }
-        return ad;
+        return debugger;
     }
 
-    private void findGeoms(Node node, List<Geometry> geoms) {
-        for (Spatial spatial : node.getChildren()) {
-            if (spatial instanceof Geometry) {
-                geoms.add((Geometry) spatial);
-            } else if (spatial instanceof Node) {
-                findGeoms((Node) spatial, geoms);
+    /**
+     * Recursively finds all `Geometry` instances within a given `Node` and its children.
+     *
+     * @param node The starting `Node` to search from.
+     * @param geometries The list to which found `Geometry` instances will be added.
+     */
+    private void collectGeometries(Node node, List<Geometry> geometries) {
+        for (Spatial s : node.getChildren()) {
+            if (s instanceof Geometry) {
+                geometries.add((Geometry) s);
+            } else if (s instanceof Node) {
+                collectGeometries((Node) s, geometries);
             }
         }
     }
 
-    final private ActionListener actionListener = new ActionListener() {
+    /**
+     * The ActionListener implementation to handle input events.
+     * Specifically, it processes mouse clicks for joint selection and
+     * the F10 key press for toggling display of all joints.
+     */
+    private final ActionListener actionListener = new ActionListener() {
+
+        private final CollisionResults results = new CollisionResults();
+
         @Override
         public void onAction(String name, boolean isPressed, float tpf) {
-            if (name.equals("shoot") && isPressed) {
-                clickDelay = 0;
-            }
-            if (name.equals("shoot") && !isPressed && clickDelay < CLICK_MAX_DELAY) {
-                Vector2f click2d = app.getInputManager().getCursorPosition();
-                CollisionResults results = new CollisionResults();
-
-                Camera camera = app.getCamera();
-                Vector3f click3d = camera.getWorldCoordinates(click2d, 0f, tmp);
-                Vector3f dir = camera.getWorldCoordinates(click2d, 1f, tmp2)
-                        .subtractLocal(click3d)
-                        .normalizeLocal();
-                Ray ray = new Ray(click3d, dir);
-                debugNode.collideWith(ray, results);
-
-                if (results.size() == 0) {
-                    for (ArmatureDebugger ad : armatures.values()) {
-                        ad.select(null);
-                    }
-                    return;
-                }
-                
-                // The closest result is the target that the player picked:
-                Geometry target = results.getClosestCollision().getGeometry();
-                for (ArmatureDebugger ad : armatures.values()) {
-                    Joint selectedjoint = ad.select(target);
-                    if (selectedjoint != null) {
-                        selectedBones.put(ad.getArmature(), selectedjoint);
-                        System.err.println("-----------------------");
-                        System.err.println("Selected Joint : " + selectedjoint.getName() + " in armature " + ad.getName());
-                        System.err.println("Root Bone : " + (selectedjoint.getParent() == null));
-                        System.err.println("-----------------------");
-                        System.err.println("Local translation: " + selectedjoint.getLocalTranslation());
-                        System.err.println("Local rotation: " + selectedjoint.getLocalRotation());
-                        System.err.println("Local scale: " + selectedjoint.getLocalScale());
-                        System.err.println("---");
-                        System.err.println("Model translation: " + selectedjoint.getModelTransform().getTranslation());
-                        System.err.println("Model rotation: " + selectedjoint.getModelTransform().getRotation());
-                        System.err.println("Model scale: " + selectedjoint.getModelTransform().getScale());
-                        System.err.println("---");
-                        System.err.println("Bind inverse Transform: ");
-                        System.err.println(selectedjoint.getInverseModelBindMatrix());
-                        return;
+            if (name.equals(PICK_JOINT)) {
+                if (isPressed) {
+                    // Start counting click delay on mouse press
+                    clickDelay = 0;
+
+                } else if (clickDelay < CLICK_MAX_DELAY) {
+                    // Process click only if it's a quick release (not a hold)
+                    Ray ray = screenPointToRay(cam, inputManager.getCursorPosition());
+                    results.clear();
+                    debugNode.collideWith(ray, results);
+
+                    if (results.size() == 0) {
+                        // If no collision, deselect all joints in all armatures
+                        for (ArmatureDebugger ad : armatures.values()) {
+                            ad.select(null);
+                        }
+                    } else {
+                        // Get the closest geometry hit by the ray
+                        Geometry target = results.getClosestCollision().getGeometry();
+                        logger.log(Level.INFO, "Pick: {0}", target);
+
+                        for (ArmatureDebugger ad : armatures.values()) {
+                            Joint selectedjoint = ad.select(target);
+
+                            if (selectedjoint != null) {
+                                // If a joint was selected, notify it and print its properties
+                                notifySelectionListeners(selectedjoint);
+                                printJointInfo(selectedjoint, ad);
+                                break;
+                            }
+                        }
                     }
                 }
             }
-            if (name.equals("toggleJoints") && isPressed) {
-                displayAllJoints = !displayAllJoints;
+            else if (name.equals(TOGGLE_JOINTS) && isPressed) {
+                displayNonDeformingJoints = !displayNonDeformingJoints;
                 for (ArmatureDebugger ad : armatures.values()) {
-                    ad.displayNonDeformingJoint(displayAllJoints);
+                    ad.displayNonDeformingJoint(displayNonDeformingJoints);
                 }
             }
         }
+
+        private void printJointInfo(Joint selectedjoint, ArmatureDebugger ad) {
+            if (enableJointInfoLogging) {
+                System.err.println("-----------------------");
+                System.err.println("Selected Joint : " + selectedjoint.getName() + " in armature " + ad.getName());
+                System.err.println("Root Bone : " + (selectedjoint.getParent() == null));
+                System.err.println("-----------------------");
+                System.err.println("Local translation: " + selectedjoint.getLocalTranslation());
+                System.err.println("Local rotation: " + selectedjoint.getLocalRotation());
+                System.err.println("Local scale: " + selectedjoint.getLocalScale());
+                System.err.println("---");
+                System.err.println("Model translation: " + selectedjoint.getModelTransform().getTranslation());
+                System.err.println("Model rotation: " + selectedjoint.getModelTransform().getRotation());
+                System.err.println("Model scale: " + selectedjoint.getModelTransform().getScale());
+                System.err.println("---");
+                System.err.println("Bind inverse Transform: ");
+                System.err.println(selectedjoint.getInverseModelBindMatrix());
+            }
+        }
+
+        /**
+         * Creates a `Ray` from a 2D screen point (e.g., mouse cursor position).
+         *
+         * @param cam The camera to use for ray projection.
+         * @param screenPoint The 2D screen coordinates.
+         * @return A `Ray` originating from the near plane and extending into the scene.
+         */
+        private Ray screenPointToRay(Camera cam, Vector2f screenPoint) {
+            TempVars vars = TempVars.get();
+            Vector3f nearPoint = vars.vect1;
+            Vector3f farPoint = vars.vect2;
+
+            // Get the world coordinates for the near and far points
+            cam.getWorldCoordinates(screenPoint, 0, nearPoint);
+            cam.getWorldCoordinates(screenPoint, 1, farPoint);
+
+            // Calculate direction and normalize
+            Vector3f direction = farPoint.subtractLocal(nearPoint).normalizeLocal();
+            Ray ray = new Ray(nearPoint, direction);
+
+            vars.release();
+            return ray;
+        }
     };
 
-//    public Map<Skeleton, Bone> getSelectedBones() {
-//        return selectedBones;
-//    }
+    /**
+     * Notifies all registered {@code Consumer<Joint>} listeners about the selected joint.
+     *
+     * @param selectedJoint The joint that was selected.
+     */
+    private void notifySelectionListeners(Joint selectedJoint) {
+        for (Consumer<Joint> listener : selectionListeners) {
+            listener.accept(selectedJoint);
+        }
+    }
+
+    /**
+     * Adds a listener that will be notified when a joint is selected.
+     *
+     * @param listener The {@code Consumer<Joint>} listener to add.
+     */
+    public void addSelectionListener(Consumer<Joint> listener) {
+        selectionListeners.add(listener);
+    }
+
+    /**
+     * Removes a previously added selection listener.
+     *
+     * @param listener The {@code Consumer<Joint>} listener to remove.
+     */
+    public void removeSelectionListener(Consumer<Joint> listener) {
+        selectionListeners.remove(listener);
+    }
+
+    /**
+     * Clears all registered selection listeners.
+     */
+    public void clearSelectionListeners() {
+        selectionListeners.clear();
+    }
+
+    /**
+     * Checks if the armature debug gizmos are set to always
+     * render on top of other scene geometry.
+     *
+     * @return true if gizmos always render on top, false otherwise.
+     */
+    public boolean isShowOnTop() {
+        return showOnTop;
+    }
+
+    /**
+     * Sets whether armature debug gizmos should always
+     * render on top of other scene geometry.
+     *
+     * @param showOnTop true to always show gizmos on top, false to respect depth.
+     */
+    public void setShowOnTop(boolean showOnTop) {
+        this.showOnTop = showOnTop;
+        if (vp != null) {
+            vp.setClearDepth(showOnTop);
+        }
+    }
 
-    public Node getDebugNode() {
-        return debugNode;
+    /**
+     * Returns whether logging of detailed joint information to `System.err` is currently enabled.
+     *
+     * @return true if logging is enabled, false otherwise.
+     */
+    public boolean isJointInfoLoggingEnabled() {
+        return enableJointInfoLogging;
     }
 
-    public void setDebugNode(Node debugNode) {
-        this.debugNode = debugNode;
+    /**
+     * Sets whether logging of detailed joint information to `System.err` should be enabled.
+     *
+     * @param enableJointInfoLogging true to enable logging, false to disable.
+     */
+    public void setJointInfoLoggingEnabled(boolean enableJointInfoLogging) {
+        this.enableJointInfoLogging = enableJointInfoLogging;
     }
 
-    private class JointInfoVisitor extends SceneGraphVisitorAdapter {
+    /**
+     * A utility visitor class to traverse the scene graph and identify
+     * which joints in a given armature are actually deforming a mesh.
+     */
+    private static class JointInfoVisitor extends SceneGraphVisitorAdapter {
 
-        List<Joint> deformingJoints = new ArrayList<>();
-        Armature armature;
+        private final List<Joint> deformingJoints = new ArrayList<>();
+        private final Armature armature;
 
+        /**
+         * Constructs a JointInfoVisitor for a specific armature.
+         *
+         * @param armature The armature whose deforming joints are to be identified.
+         */
         public JointInfoVisitor(Armature armature) {
             this.armature = armature;
         }
 
+        /**
+         * Visits a Geometry node in the scene graph.
+         * For each Geometry, it checks all joints in the associated armature
+         * to see if they influence this mesh.
+         *
+         * @param geo The Geometry node being visited.
+         */
         @Override
-        public void visit(Geometry g) {
+        public void visit(Geometry geo) {
             for (Joint joint : armature.getJointList()) {
-                if (g.getMesh().isAnimatedByJoint(armature.getJointIndex(joint))) {
+                int index = armature.getJointIndex(joint);
+                if (geo.getMesh().isAnimatedByJoint(index)) {
                     deformingJoints.add(joint);
                 }
             }
         }
+
+    }
+
+    private static class ArmatureDebuggerLink extends AbstractControl {
+
+        private final Spatial target;
+
+        public ArmatureDebuggerLink(Spatial target) {
+            this.target = target;
+        }
+
+        @Override
+        protected void controlUpdate(float tpf) {
+            spatial.setLocalTransform(target.getWorldTransform());
+        }
+
+        @Override
+        protected void controlRender(RenderManager rm, ViewPort vp) {
+        }
     }
 }

+ 136 - 50
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugger.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,9 +29,11 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
+package com.jme3.scene.debug.custom;
 
 import com.jme3.anim.Armature;
 import com.jme3.anim.Joint;
+import com.jme3.anim.SkinningControl;
 import com.jme3.asset.AssetManager;
 import com.jme3.collision.Collidable;
 import com.jme3.collision.CollisionResults;
@@ -55,104 +55,176 @@ import java.util.List;
 public class ArmatureDebugger extends Node {
 
     /**
-     * The lines of the bones or the wires between their heads.
+     * The node responsible for rendering the bones/wires and their outlines.
      */
     private ArmatureNode armatureNode;
-
+    /**
+     * The {@link Armature} instance being debugged.
+     */
     private Armature armature;
-
+    /**
+     * A node containing all {@link Geometry} objects representing the joint points.
+     */
     private Node joints;
+    /**
+     * A node containing all {@link Geometry} objects representing the bone outlines.
+     */
     private Node outlines;
+    /**
+     * A node containing all {@link Geometry} objects representing the bone wires/lines.
+     */
     private Node wires;
     /**
      * The dotted lines between a bone's tail and the had of its children. Not
      * available if the length data was not provided.
      */
     private ArmatureInterJointsWire interJointWires;
-
+    /**
+     * Default constructor for `ArmatureDebugger`.
+     * Use {@link #ArmatureDebugger(String, Armature, List)} for a functional instance.
+     */
     public ArmatureDebugger() {
     }
 
     /**
-     * Creates a debugger with no length data. The wires will be a connection
-     * between the bones' heads only. The points will show the bones' heads only
-     * and no dotted line of inter bones connection will be visible.
+     * Convenience constructor that creates an {@code ArmatureDebugger} and immediately
+     * initializes its materials based on the provided {@code AssetManager}
+     * and {@code SkinningControl}.
+     *
+     * @param assetManager The {@link AssetManager} used to load textures and materials
+     *                     for the debug visualization.
+     * @param skControl    The {@link SkinningControl} from which to extract the
+     *                     {@link Armature} and its associated joints.
+     */
+    public ArmatureDebugger(AssetManager assetManager, SkinningControl skControl) {
+        this(null, skControl.getArmature(), skControl.getArmature().getJointList());
+        initialize(assetManager, null);
+    }
+
+    /**
+     * Creates an `ArmatureDebugger` instance without explicit bone length data.
+     * In this configuration, the visual representation will consist of wires
+     * connecting the bone heads, and points representing the bone heads.
+     * No dotted lines for inter-bone connections will be visible.
      *
-     * @param name     the name of the debugger's node
-     * @param armature the armature that will be shown
-     * @param deformingJoints a list of joints
+     * @param name            The name of this debugger's root node.
+     * @param armature        The {@link Armature} to be visualized.
+     * @param deformingJoints A {@link List} of {@link Joint} objects that are
+     *                        considered deforming joints.
      */
     public ArmatureDebugger(String name, Armature armature, List<Joint> deformingJoints) {
         super(name);
         this.armature = armature;
+        // Ensure the armature's world transforms are up-to-date before visualization.
         armature.update();
 
+        // Initialize the main container nodes for different visual elements.
         joints = new Node("joints");
         outlines = new Node("outlines");
         wires = new Node("bones");
         this.attachChild(joints);
         this.attachChild(outlines);
         this.attachChild(wires);
-        Node ndJoints = new Node("non deforming Joints");
-        Node ndOutlines = new Node("non deforming Joints outlines");
-        Node ndWires = new Node("non deforming Joints wires");
-        joints.attachChild(ndJoints);
-        outlines.attachChild(ndOutlines);
-        wires.attachChild(ndWires);
-        Node outlineDashed = new Node("Outlines Dashed");
-        Node wiresDashed = new Node("Wires Dashed");
-        wiresDashed.attachChild(new Node("dashed non defrom"));
-        outlineDashed.attachChild(new Node("dashed non defrom"));
+
+        // Create child nodes specifically for non-deforming joints' visualization
+        joints.attachChild(new Node("NonDeformingJoints"));
+        outlines.attachChild(new Node("NonDeformingOutlines"));
+        wires.attachChild(new Node("NonDeformingWires"));
+
+        Node outlineDashed = new Node("DashedOutlines");
+        outlineDashed.attachChild(new Node("DashedNonDeformingOutlines"));
         outlines.attachChild(outlineDashed);
+
+        Node wiresDashed = new Node("DashedWires");
+        wiresDashed.attachChild(new Node("DashedNonDeformingWires"));
         wires.attachChild(wiresDashed);
 
+        // Initialize the core ArmatureNode which handles the actual mesh generation.
         armatureNode = new ArmatureNode(armature, joints, wires, outlines, deformingJoints);
-
         this.attachChild(armatureNode);
 
+        // By default, non-deforming joints are hidden.
         displayNonDeformingJoint(false);
     }
 
+    /**
+     * Sets the visibility of non-deforming joints and their associated outlines and wires.
+     *
+     * @param display `true` to make non-deforming joints visible, `false` to hide them.
+     */
     public void displayNonDeformingJoint(boolean display) {
-        joints.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        outlines.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        wires.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        ((Node) outlines.getChild(1)).getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        ((Node) wires.getChild(1)).getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
+        CullHint cullHint = display ? CullHint.Dynamic : CullHint.Always;
+
+        joints.getChild(0).setCullHint(cullHint);
+        outlines.getChild(0).setCullHint(cullHint);
+        wires.getChild(0).setCullHint(cullHint);
+
+        ((Node) outlines.getChild(1)).getChild(0).setCullHint(cullHint);
+        ((Node) wires.getChild(1)).getChild(0).setCullHint(cullHint);
     }
 
+    /**
+     * Initializes the materials and camera for the debugger's visual components.
+     * This method should be called after the `ArmatureDebugger` is added to a scene graph
+     * and an {@link AssetManager} and {@link Camera} are available.
+     *
+     * @param assetManager The {@link AssetManager} to load textures and materials.
+     * @param camera       The scene's primary {@link Camera}, used by the `ArmatureNode`
+     * for billboard rendering of joint points.
+     */
     public void initialize(AssetManager assetManager, Camera camera) {
 
         armatureNode.setCamera(camera);
 
-        Material matJoints = new Material(assetManager, "Common/MatDefs/Misc/Billboard.j3md");
-        Texture t = assetManager.loadTexture("Common/Textures/dot.png");
-        matJoints.setTexture("Texture", t);
-        matJoints.getAdditionalRenderState().setDepthTest(false);
-        matJoints.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        // Material for joint points (billboard dots).
+        Material matJoints = getJointMaterial(assetManager);
         joints.setQueueBucket(RenderQueue.Bucket.Translucent);
         joints.setMaterial(matJoints);
 
-        Material matWires = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
-        matWires.setBoolean("VertexColor", true);
-        matWires.getAdditionalRenderState().setLineWidth(1f);
+        // Material for bone wires/lines (unshaded, vertex colored).
+        Material matWires = getUnshadedMaterial(assetManager);
         wires.setMaterial(matWires);
 
-        Material matOutline = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
-        matOutline.setBoolean("VertexColor", true);
-        matOutline.getAdditionalRenderState().setLineWidth(1f);
+        // Material for dashed wires ("DashedLine.j3md" shader).
+        Material matWires2 = getDashedMaterial(assetManager);
+        wires.getChild(1).setMaterial(matWires2);
+
+        // Material for bone outlines (unshaded, vertex colored).
+        Material matOutline = getUnshadedMaterial(assetManager);
         outlines.setMaterial(matOutline);
 
-        Material matOutline2 = new Material(assetManager, "Common/MatDefs/Misc/DashedLine.j3md");
-        matOutline2.getAdditionalRenderState().setLineWidth(1);
+        // Material for dashed outlines ("DashedLine.j3md" shader).
+        Material matOutline2 = getDashedMaterial(assetManager);
         outlines.getChild(1).setMaterial(matOutline2);
+    }
 
-        Material matWires2 = new Material(assetManager, "Common/MatDefs/Misc/DashedLine.j3md");
-        matWires2.getAdditionalRenderState().setLineWidth(1);
-        wires.getChild(1).setMaterial(matWires2);
+    private Material getJointMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/Billboard.j3md");
+        Texture tex = asm.loadTexture("Common/Textures/dot.png");
+        mat.setTexture("Texture", tex);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        return mat;
+    }
+
+    private Material getUnshadedMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setBoolean("VertexColor", true);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        return mat;
+    }
 
+    private Material getDashedMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/DashedLine.j3md");
+        mat.getAdditionalRenderState().setDepthTest(false);
+        return mat;
     }
 
+    /**
+     * Returns the {@link Armature} instance associated with this debugger.
+     *
+     * @return The {@link Armature} being debugged.
+     */
     public Armature getArmature() {
         return armature;
     }
@@ -168,21 +240,35 @@ public class ArmatureDebugger extends Node {
         return armatureNode.collideWith(other, results);
     }
 
-    protected Joint select(Geometry g) {
-        return armatureNode.select(g);
+    /**
+     * Selects and returns the {@link Joint} associated with a given {@link Geometry}.
+     * This is an internal helper method, likely used for picking operations.
+     *
+     * @param geo The {@link Geometry} representing a part of a joint.
+     * @return The {@link Joint} corresponding to the geometry, or `null` if not found.
+     */
+    protected Joint select(Geometry geo) {
+        return armatureNode.select(geo);
     }
 
     /**
-     * @return the armature wires
+     * Returns the {@link ArmatureNode} which is responsible for generating and
+     * managing the visual mesh of the bones and wires.
+     *
+     * @return The {@link ArmatureNode} instance.
      */
     public ArmatureNode getBoneShapes() {
         return armatureNode;
     }
 
     /**
-     * @return the dotted line between bones (can be null)
+     * Returns the {@link ArmatureInterJointsWire} instance, which represents the
+     * dotted lines connecting a bone's tail to the head of its children.
+     * This will be `null` if the debugger was created without bone length data.
+     *
+     * @return The {@link ArmatureInterJointsWire} instance, or `null` if not present.
      */
     public ArmatureInterJointsWire getInterJointWires() {
         return interJointWires;
     }
-}
+}

+ 26 - 10
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureInterJointsWire.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,7 +29,7 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
-
+package com.jme3.scene.debug.custom;
 
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Mesh;
@@ -46,20 +44,38 @@ import java.nio.FloatBuffer;
  * @author Marcin Roguski (Kaelthas)
  */
 public class ArmatureInterJointsWire extends Mesh {
-    private final Vector3f tmp = new Vector3f();
 
+    /**
+     * A temporary {@link Vector3f} used for calculations to avoid object allocation.
+     */
+    private final Vector3f tempVec = new Vector3f();
+
+    /**
+     * For serialization only. Do not use.
+     */
+    protected ArmatureInterJointsWire() {
+    }
 
+    /**
+     * Creates a new {@code ArmatureInterJointsWire} mesh.
+     * The mesh will be set up to draw lines from the {@code start} vector to each of the {@code ends} vectors.
+     *
+     * @param start The starting point of the lines (e.g., the bone tail's position). Not null.
+     * @param ends An array of ending points for the lines (e.g., the children's head positions). Not null.
+     */
     public ArmatureInterJointsWire(Vector3f start, Vector3f[] ends) {
         setMode(Mode.Lines);
         updateGeometry(start, ends);
     }
 
     /**
-     * For serialization only. Do not use.
+     * Updates the geometry of this mesh based on the provided start and end points.
+     * This method re-generates the position, texture coordinate, normal, and index buffers
+     * for the mesh.
+     *
+     * @param start The new starting point for the lines. Not null.
+     * @param ends An array of new ending points for the lines. Not null.
      */
-    protected ArmatureInterJointsWire() {
-    }
-
     protected void updateGeometry(Vector3f start, Vector3f[] ends) {
         float[] pos = new float[ends.length * 3 + 3];
         pos[0] = start.x;
@@ -78,7 +94,7 @@ public class ArmatureInterJointsWire extends Mesh {
         texCoord[0] = 0;
         texCoord[1] = 0;
         for (int i = 0; i < ends.length * 2; i++) {
-            texCoord[i + 2] = tmp.set(start).subtractLocal(ends[i / 2]).length();
+            texCoord[i + 2] = tempVec.set(start).subtractLocal(ends[i / 2]).length();
         }
         setBuffer(Type.TexCoord, 2, texCoord);
 

+ 215 - 57
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureNode.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,43 +29,73 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
+package com.jme3.scene.debug.custom;
 
 import com.jme3.anim.Armature;
 import com.jme3.anim.Joint;
-import com.jme3.collision.*;
-import com.jme3.math.*;
+import com.jme3.collision.Collidable;
+import com.jme3.collision.CollisionResult;
+import com.jme3.collision.CollisionResults;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.MathUtils;
+import com.jme3.math.Ray;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.Camera;
 import com.jme3.renderer.queue.RenderQueue;
-import com.jme3.scene.*;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.shape.Line;
 
 import java.nio.FloatBuffer;
-import java.util.*;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
- * The class that displays either wires between the bones' heads if no length
- * data is supplied and full bones' shapes otherwise.
+ * Renders an {@link Armature} for debugging purposes. It can display either
+ * wires connecting the heads of bones (if no length data is available) or
+ * full bone shapes (from head to tail) when length data is supplied.
  */
 public class ArmatureNode extends Node {
 
+    /**
+     * The size of the picking box in pixels for joint selection.
+     */
     public static final float PIXEL_BOX = 10f;
     /**
      * The armature to be displayed.
      */
     private final Armature armature;
     /**
-     * The map between the bone index and its length.
+     * Maps a {@link Joint} to its corresponding {@link Geometry} array.
+     * The array typically contains [jointGeometry, boneWireGeometry, boneOutlineGeometry].
      */
     private final Map<Joint, Geometry[]> jointToGeoms = new HashMap<>();
+    /**
+     * Maps a {@link Geometry} to its associated {@link Joint}. Used for picking.
+     */
     private final Map<Geometry, Joint> geomToJoint = new HashMap<>();
+    /**
+     * The currently selected joint.
+     */
     private Joint selectedJoint = null;
-    private final Vector3f tmp = new Vector3f();
-    private final Vector2f tmpv2 = new Vector2f();
+
+    // Temporary vectors for calculations to avoid repeated allocations
+    private final Vector3f tempVec3f = new Vector3f();
+    private final Vector2f tempVec2f = new Vector2f();
+
+    // Color constants for rendering
     private static final ColorRGBA selectedColor = ColorRGBA.Orange;
-    private static final ColorRGBA selectedColorJ = ColorRGBA.Yellow;
+    private static final ColorRGBA selectedColorJoint = ColorRGBA.Yellow;
     private static final ColorRGBA outlineColor = ColorRGBA.LightGray;
     private static final ColorRGBA baseColor = new ColorRGBA(0.05f, 0.05f, 0.05f, 1f);
 
+    /**
+     * The camera used for 2D picking calculations.
+     */
     private Camera camera;
 
 
@@ -88,27 +116,36 @@ public class ArmatureNode extends Node {
         setColor(origin, ColorRGBA.Green);
         attach(joints, true, origin);
 
+        // Recursively create geometries for all joints and bones in the armature
         for (Joint joint : armature.getRoots()) {
             createSkeletonGeoms(joint, joints, wires, outlines, deformingJoints);
         }
         this.updateModelBound();
-
     }
 
+    /**
+     * Recursively creates the geometries for a given joint and its children.
+     *
+     * @param joint           The current joint for which to create geometries.
+     * @param joints          The node for joint geometries.
+     * @param wires           The node for bone wire geometries.
+     * @param outlines        The node for bone outline geometries.
+     * @param deformingJoints A list of deforming joints.
+     */
     protected final void createSkeletonGeoms(Joint joint, Node joints, Node wires, Node outlines, List<Joint> deformingJoints) {
         Vector3f start = joint.getModelTransform().getTranslation().clone();
 
         Vector3f[] ends = null;
         if (!joint.getChildren().isEmpty()) {
             ends = new Vector3f[joint.getChildren().size()];
-        }
-
-        for (int i = 0; i < joint.getChildren().size(); i++) {
-            ends[i] = joint.getChildren().get(i).getModelTransform().getTranslation().clone();
+            for (int i = 0; i < ends.length; i++) {
+                ends[i] = joint.getChildren().get(i).getModelTransform().getTranslation().clone();
+            }
         }
 
         boolean deforms = deformingJoints.contains(joint);
 
+        // Create geometry for the joint head
         Geometry jGeom = new Geometry(joint.getName() + "Joint", new JointShape());
         jGeom.setLocalTranslation(start);
         attach(joints, deforms, jGeom);
@@ -134,8 +171,8 @@ public class ArmatureNode extends Node {
             setColor(bGeom, outlinesAttach == null ? outlineColor : baseColor);
             geomToJoint.put(bGeom, joint);
             bGeom.setUserData("start", getWorldTransform().transformVector(start, start));
-            for (int i = 0; i < ends.length; i++) {
-                getWorldTransform().transformVector(ends[i], ends[i]);
+            for (Vector3f end : ends) {
+                getWorldTransform().transformVector(end, end);
             }
             bGeom.setUserData("end", ends);
             bGeom.setQueueBucket(RenderQueue.Bucket.Transparent);
@@ -148,11 +185,17 @@ public class ArmatureNode extends Node {
         }
         jointToGeoms.put(joint, new Geometry[]{jGeom, bGeom, bGeomO});
 
+        // Recursively call for children
         for (Joint child : joint.getChildren()) {
             createSkeletonGeoms(child, joints, wires, outlines, deformingJoints);
         }
     }
 
+    /**
+     * Sets the camera to be used for 2D picking calculations.
+     *
+     * @param camera The camera to set.
+     */
     public void setCamera(Camera camera) {
         this.camera = camera;
     }
@@ -165,53 +208,83 @@ public class ArmatureNode extends Node {
         }
     }
 
-    protected Joint select(Geometry g) {
-        if (g == null) {
+    /**
+     * Selects a joint based on its associated geometry.
+     * If the selected geometry is already the current selection, no change occurs.
+     * Resets the selection if {@code geometry} is null.
+     *
+     * @param geo The geometry representing the joint or bone to select.
+     * @return The newly selected {@link Joint}, or null if no joint was selected or the selection was reset.
+     */
+    protected Joint select(Geometry geo) {
+        if (geo == null) {
             resetSelection();
             return null;
         }
-        Joint j = geomToJoint.get(g);
-        if (j != null) {
-            if (selectedJoint == j) {
+        Joint jointToSelect = geomToJoint.get(geo);
+        if (jointToSelect != null) {
+            if (selectedJoint == jointToSelect) {
                 return null;
             }
             resetSelection();
-            selectedJoint = j;
+            selectedJoint = jointToSelect;
             Geometry[] geomArray = jointToGeoms.get(selectedJoint);
-            setColor(geomArray[0], selectedColorJ);
+            // Color the joint head
+            setColor(geomArray[0], selectedColorJoint);
 
+            // Color the bone wire
             if (geomArray[1] != null) {
                 setColor(geomArray[1], selectedColor);
             }
 
+            // Restore outline color if present (as it's often the base color when bone is selected)
             if (geomArray[2] != null) {
                 setColor(geomArray[2], baseColor);
             }
-            return j;
+            return jointToSelect;
         }
         return null;
     }
 
+    /**
+     * Resets the color of the currently selected joint and bone geometries to their default colors
+     * and clears the {@code selectedJoint}.
+     */
     private void resetSelection() {
         if (selectedJoint == null) {
             return;
         }
         Geometry[] geoms = jointToGeoms.get(selectedJoint);
+        // Reset joint head color
         setColor(geoms[0], ColorRGBA.White);
+
+        // Reset bone wire color (depends on whether it has an outline)
         if (geoms[1] != null) {
             setColor(geoms[1], geoms[2] == null ? outlineColor : baseColor);
         }
+
+        // Reset bone outline color
         if (geoms[2] != null) {
             setColor(geoms[2], outlineColor);
         }
         selectedJoint = null;
     }
 
+    /**
+     * Returns the currently selected joint.
+     *
+     * @return The {@link Joint} that is currently selected, or null if no joint is selected.
+     */
     protected Joint getSelectedJoint() {
         return selectedJoint;
     }
 
-
+    /**
+     * Updates the geometries associated with a given joint and its children to reflect their
+     * current model transforms. This method is called recursively.
+     *
+     * @param joint The joint to update.
+     */
     protected final void updateSkeletonGeoms(Joint joint) {
         Geometry[] geoms = jointToGeoms.get(joint);
         if (geoms != null) {
@@ -232,70 +305,142 @@ public class ArmatureNode extends Node {
                         updateBoneMesh(bGeomO, start, ends);
                     }
                     bGeom.setUserData("start", getWorldTransform().transformVector(start, start));
-                    for (int i = 0; i < ends.length; i++) {
-                        getWorldTransform().transformVector(ends[i], ends[i]);
+                    for (Vector3f end : ends) {
+                        getWorldTransform().transformVector(end, end);
                     }
                     bGeom.setUserData("end", ends);
-
                 }
             }
         }
 
+        // Recursively update children
         for (Joint child : joint.getChildren()) {
             updateSkeletonGeoms(child);
         }
     }
 
+    /**
+     * Sets the color of the head geometry for a specific joint.
+     *
+     * @param joint The joint whose head color is to be set.
+     * @param color The new color for the joint head.
+     */
+    public void setHeadColor(Joint joint, ColorRGBA color) {
+        Geometry[] geomArray = jointToGeoms.get(joint);
+        setColor(geomArray[0], color);
+    }
+
+    /**
+     * Sets the color of all joint head geometries.
+     *
+     * @param color The new color for all joint heads.
+     */
+    public void setHeadColor(ColorRGBA color) {
+        for (Geometry[] geomArray : jointToGeoms.values()) {
+            setColor(geomArray[0], color);
+        }
+    }
+
+    /**
+     * Sets the color of all bone line geometries.
+     *
+     * @param color The new color for all bone lines.
+     */
+    public void setLineColor(ColorRGBA color) {
+        for (Geometry[] geomArray : jointToGeoms.values()) {
+            if (geomArray[1] != null) {
+                setColor(geomArray[1], color);
+            }
+        }
+    }
+
+    /**
+     * Performs a 2D pick operation to find joints or bones near the given cursor position.
+     * This method primarily checks for joint heads within a {@link #PIXEL_BOX} box
+     * around the cursor, and then checks for bone wires.
+     *
+     * @param cursor  The 2D screen coordinates of the pick ray origin.
+     * @param results The {@link CollisionResults} to store the pick results.
+     * @return The number of collisions found.
+     */
     public int pick(Vector2f cursor, CollisionResults results) {
+        if (camera == null) {
+            return 0;
+        }
 
-        for (Geometry g : geomToJoint.keySet()) {
-            if (g.getMesh() instanceof JointShape) {
-                camera.getScreenCoordinates(g.getWorldTranslation(), tmp);
-                if (cursor.x <= tmp.x + PIXEL_BOX && cursor.x >= tmp.x - PIXEL_BOX
-                        && cursor.y <= tmp.y + PIXEL_BOX && cursor.y >= tmp.y - PIXEL_BOX) {
+        int collisions = 0;
+        for (Geometry geo : geomToJoint.keySet()) {
+            if (geo.getMesh() instanceof JointShape) {
+                camera.getScreenCoordinates(geo.getWorldTranslation(), tempVec3f);
+                if (cursor.x <= tempVec3f.x + PIXEL_BOX && cursor.x >= tempVec3f.x - PIXEL_BOX
+                        && cursor.y <= tempVec3f.y + PIXEL_BOX && cursor.y >= tempVec3f.y - PIXEL_BOX) {
                     CollisionResult res = new CollisionResult();
-                    res.setGeometry(g);
+                    res.setGeometry(geo);
                     results.addCollision(res);
+                    collisions++;
                 }
             }
         }
-        return 0;
+        return collisions;
     }
 
+    /**
+     * Collides this {@code ArmatureNode} with a {@link Collidable} object, typically a {@link Ray}.
+     * It prioritizes 2D picking for joint heads and then performs a distance-based check for bone wires.
+     *
+     * @param other   The {@link Collidable} object to collide with.
+     * @param results The {@link CollisionResults} to store the collision information.
+     * @return The number of collisions found.
+     */
     @Override
     public int collideWith(Collidable other, CollisionResults results) {
-        if (!(other instanceof Ray)) {
+        if (!(other instanceof Ray) || camera == null) {
             return 0;
         }
 
-        // first try a 2D pick;
-        camera.getScreenCoordinates(((Ray)other).getOrigin(),tmp);
-        tmpv2.x = tmp.x;
-        tmpv2.y = tmp.y;
-        int nbHit = pick(tmpv2, results);
-        if (nbHit > 0) {
-            return nbHit;
+        // First, try a 2D pick for joint heads
+        camera.getScreenCoordinates(((Ray) other).getOrigin(), tempVec3f);
+        tempVec2f.x = tempVec3f.x;
+        tempVec2f.y = tempVec3f.y;
+        int hitCount = pick(tempVec2f, results);
+
+        // If 2D pick found hits, return them. Otherwise, proceed with bone wire collision.
+        if (hitCount > 0) {
+            return hitCount;
         }
 
+        // Check for bone wire collisions
         for (Geometry g : geomToJoint.keySet()) {
             if (g.getMesh() instanceof JointShape) {
+                // Skip joint heads, already handled by 2D pick
                 continue;
             }
+
             Vector3f start = g.getUserData("start");
             Vector3f[] ends = g.getUserData("end");
-            for (int i = 0; i < ends.length; i++) {
-                float len = MathUtils.raySegmentShortestDistance((Ray) other, start, ends[i], camera);
-                if (len > 0 && len < PIXEL_BOX) {
+
+            for (Vector3f end : ends) {
+                // Calculate the shortest distance from ray to bone segment
+                float dist = MathUtils.raySegmentShortestDistance((Ray) other, start, end, camera);
+                if (dist > 0 && dist < PIXEL_BOX) {
                     CollisionResult res = new CollisionResult();
                     res.setGeometry(g);
                     results.addCollision(res);
-                    nbHit++;
+                    hitCount++;
                 }
             }
         }
-        return nbHit;
+        return hitCount;
     }
 
+    /**
+     * Updates the mesh of a bone geometry (either {@link ArmatureInterJointsWire} or {@link Line})
+     * with new start and end points.
+     *
+     * @param geom  The bone geometry whose mesh needs updating.
+     * @param start The new starting point of the bone.
+     * @param ends  The new ending points of the bone (can be multiple for {@link ArmatureInterJointsWire}).
+     */
     private void updateBoneMesh(Geometry geom, Vector3f start, Vector3f[] ends) {
         if (geom.getMesh() instanceof ArmatureInterJointsWire) {
             ((ArmatureInterJointsWire) geom.getMesh()).updatePoints(start, ends);
@@ -305,18 +450,31 @@ public class ArmatureNode extends Node {
         geom.updateModelBound();
     }
 
-    private void setColor(Geometry g, ColorRGBA color) {
-        float[] colors = new float[g.getMesh().getVertexCount() * 4];
-        for (int i = 0; i < g.getMesh().getVertexCount() * 4; i += 4) {
+    /**
+     * Sets the color of a given geometry's vertex buffer.
+     * This method creates a new color buffer or updates an existing one with the specified color.
+     *
+     * @param geo   The geometry whose color is to be set.
+     * @param color The {@link ColorRGBA} to apply.
+     */
+    private void setColor(Geometry geo, ColorRGBA color) {
+        Mesh mesh = geo.getMesh();
+        int vertexCount = mesh.getVertexCount();
+
+        float[] colors = new float[vertexCount * 4];
+        for (int i = 0; i < colors.length; i += 4) {
             colors[i] = color.r;
             colors[i + 1] = color.g;
             colors[i + 2] = color.b;
             colors[i + 3] = color.a;
         }
-        VertexBuffer colorBuff = g.getMesh().getBuffer(VertexBuffer.Type.Color);
+
+        VertexBuffer colorBuff = geo.getMesh().getBuffer(VertexBuffer.Type.Color);
         if (colorBuff == null) {
-            g.getMesh().setBuffer(VertexBuffer.Type.Color, 4, colors);
+            // If no color buffer exists, create a new one
+            geo.getMesh().setBuffer(VertexBuffer.Type.Color, 4, colors);
         } else {
+            // If a color buffer exists, update its data
             FloatBuffer cBuff = (FloatBuffer) colorBuff.getData();
             cBuff.rewind();
             cBuff.put(colors);

+ 110 - 13
jme3-core/src/main/java/com/jme3/scene/mesh/MorphTarget.java

@@ -1,46 +1,142 @@
+/*
+ * Copyright (c) 2009-2025 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 com.jme3.scene.mesh;
 
-import com.jme3.export.*;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.export.Savable;
 import com.jme3.scene.VertexBuffer;
 
 import java.io.IOException;
-import java.nio.Buffer;
 import java.nio.FloatBuffer;
 import java.util.EnumMap;
 import java.util.Map;
 
+/**
+ * `MorphTarget` represents a single morph target within a `Mesh`.
+ * A morph target contains a set of `FloatBuffer` instances, each corresponding
+ * to a `VertexBuffer.Type` (e.g., `POSITION`, `NORMAL`, `TANGENT`).
+ * These buffers store the delta (difference) values that, when added to the
+ * base mesh's corresponding vertex buffers, create a deformed version of the mesh.
+ * <p>
+ * Morph targets are primarily used for skeletal animation blending, facial animation,
+ * or other mesh deformation effects. Each `MorphTarget` can optionally have a name
+ * for identification and control.
+ */
 public class MorphTarget implements Savable {
+
+    /**
+     * Stores the `FloatBuffer` instances for each `VertexBuffer.Type` that
+     * this morph target affects.
+     */
     private final EnumMap<VertexBuffer.Type, FloatBuffer> buffers = new EnumMap<>(VertexBuffer.Type.class);
-    private String name = null;
-    
+    /**
+     * An optional name for this morph target, useful for identification
+     * and targeting in animations.
+     */
+    private String name;
+
+    /**
+     * Required for jME deserialization.
+     */
     public MorphTarget() {
-        
     }
-    
+
+    /**
+     * Creates a new `MorphTarget` with the specified name.
+     *
+     * @param name The name of this morph target (can be null).
+     */
     public MorphTarget(String name) {
         this.name = name;
     }
-    
+
+    /**
+     * Sets the name of this morph target.
+     *
+     * @param name The new name for this morph target (can be null).
+     */
     public void setName(String name) {
         this.name = name;
     }
-    
+
+    /**
+     * Returns the name of this morph target.
+     *
+     * @return The name of this morph target, or null if not set.
+     */
     public String getName() {
         return name;
     }
 
+    /**
+     * Associates a `FloatBuffer` with a specific `VertexBuffer.Type` for this morph target.
+     * This buffer typically contains the delta values for the specified vertex attribute.
+     *
+     * @param type The type of vertex buffer (e.g., `POSITION`, `NORMAL`).
+     * @param buffer The `FloatBuffer` containing the delta data for the given type.
+     */
     public void setBuffer(VertexBuffer.Type type, FloatBuffer buffer) {
         buffers.put(type, buffer);
     }
 
+    /**
+     * Retrieves the `FloatBuffer` associated with a specific `VertexBuffer.Type` for this morph target.
+     *
+     * @param type The type of vertex buffer.
+     * @return The `FloatBuffer` for the given type, or null if not set.
+     */
     public FloatBuffer getBuffer(VertexBuffer.Type type) {
         return buffers.get(type);
     }
 
+    /**
+     * Returns the `EnumMap` containing all the `FloatBuffer` instances
+     * associated with their `VertexBuffer.Type` for this morph target.
+     *
+     * @return An `EnumMap` of vertex buffer types to their corresponding `FloatBuffer`s.
+     */
     public EnumMap<VertexBuffer.Type, FloatBuffer> getBuffers() {
         return buffers;
     }
 
+    /**
+     * Returns the number of `FloatBuffer`s (i.e., vertex attribute types)
+     * contained within this morph target.
+     *
+     * @return The count of buffers in this morph target.
+     */
     public int getNumBuffers() {
         return buffers.size();
     }
@@ -49,8 +145,9 @@ public class MorphTarget implements Savable {
     public void write(JmeExporter ex) throws IOException {
         OutputCapsule oc = ex.getCapsule(this);
         for (Map.Entry<VertexBuffer.Type, FloatBuffer> entry : buffers.entrySet()) {
-            Buffer roData = entry.getValue().asReadOnlyBuffer();
-            oc.write((FloatBuffer) roData, entry.getKey().name(),null);
+            VertexBuffer.Type type = entry.getKey();
+            FloatBuffer roData = entry.getValue().asReadOnlyBuffer();
+            oc.write(roData, type.name(), null);
         }
         oc.write(name, "morphName", null);
     }
@@ -59,9 +156,9 @@ public class MorphTarget implements Savable {
     public void read(JmeImporter im) throws IOException {
         InputCapsule ic = im.getCapsule(this);
         for (VertexBuffer.Type type : VertexBuffer.Type.values()) {
-            FloatBuffer b = ic.readFloatBuffer(type.name(), null);
-            if(b!= null){
-                setBuffer(type, b);
+            FloatBuffer fb = ic.readFloatBuffer(type.name(), null);
+            if (fb != null) {
+                setBuffer(type, fb);
             }
         }
         name = ic.readString("morphName", null);

+ 52 - 6
jme3-core/src/main/java/com/jme3/shader/VarType.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2024 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -45,6 +45,9 @@ import com.jme3.texture.TextureArray;
 import com.jme3.texture.TextureCubeMap;
 import com.jme3.texture.TextureImage;
 
+/**
+ * Enum representing various GLSL variable types and their corresponding Java types.
+ */
 public enum VarType {
     
     Float("float", float.class, Float.class),
@@ -59,7 +62,7 @@ public enum VarType {
     Vector4Array(true, false, "vec4", Vector4f[].class),
     
     Int("int", int.class, Integer.class),
-    Boolean("bool", Boolean.class, boolean.class),
+    Boolean("bool", boolean.class, Boolean.class),
 
     Matrix3(true, false, "mat3", Matrix3f.class),
     Matrix4(true, false, "mat4", Matrix4f.class),
@@ -83,8 +86,14 @@ public enum VarType {
     private boolean textureType = false;
     private boolean imageType = false;
     private final String glslType;
-    private Class<?>[] javaTypes;
+    private final Class<?>[] javaTypes;
 
+    /**
+     * Constructs a VarType with the specified GLSL type and corresponding Java types.
+     *
+     * @param glslType  the GLSL type name(s)
+     * @param javaTypes the Java classes mapped to this GLSL type
+     */
     VarType(String glslType, Class<?>... javaTypes) {
         this.glslType = glslType;
         if (javaTypes != null) {
@@ -94,6 +103,14 @@ public enum VarType {
         }
     }
 
+    /**
+     * Constructs a VarType with additional flags for multi-data and texture types.
+     *
+     * @param multiData   true if this type uses multiple data elements (e.g. arrays, matrices)
+     * @param textureType true if this type represents a texture sampler
+     * @param glslType    the GLSL type name(s)
+     * @param javaTypes   the Java classes mapped to this GLSL type
+     */
     VarType(boolean multiData, boolean textureType, String glslType, Class<?>... javaTypes) {
         this.usesMultiData = multiData;
         this.textureType = textureType;
@@ -104,7 +121,16 @@ public enum VarType {
             this.javaTypes = new Class<?>[0];
         }
     }
-    
+
+    /**
+     * Constructs a VarType with flags for multi-data, texture, and image types.
+     *
+     * @param multiData   true if this type uses multiple data elements
+     * @param textureType true if this type represents a texture sampler
+     * @param imageType   true if this type represents an image
+     * @param glslType    the GLSL type name(s)
+     * @param javaTypes   the Java classes mapped to this GLSL type
+     */
     VarType(boolean multiData, boolean textureType, boolean imageType, String glslType, Class<?>... javaTypes) {
         this(multiData, textureType, glslType, javaTypes);
         this.imageType = imageType;
@@ -127,25 +153,45 @@ public enum VarType {
 
     /**
      * Get the java types mapped to this VarType
-     * 
+     *
      * @return an array of classes mapped to this VarType
      */
     public Class<?>[] getJavaType() {
         return javaTypes;
     }
 
+    /**
+     * Returns whether this VarType represents a texture sampler type.
+     *
+     * @return true if this is a texture type, false otherwise
+     */
     public boolean isTextureType() {
         return textureType;
     }
-    
+
+    /**
+     * Returns whether this VarType represents an image type.
+     *
+     * @return true if this is an image type, false otherwise
+     */
     public boolean isImageType() {
         return imageType;
     }
 
+    /**
+     * Returns whether this VarType uses multiple data elements (e.g. arrays or matrices).
+     *
+     * @return true if this type uses multiple data elements, false otherwise
+     */
     public boolean usesMultiData() {
         return usesMultiData;
     }
 
+    /**
+     * Returns the GLSL type name(s) associated with this VarType.
+     *
+     * @return the GLSL type string (e.g. "float", "vec3", "sampler2D")
+     */
     public String getGlslType() {
         return glslType;
     }

+ 18 - 30
jme3-core/src/main/java/com/jme3/shadow/AbstractShadowFilter.java

@@ -32,10 +32,6 @@
 package com.jme3.shadow;
 
 import com.jme3.asset.AssetManager;
-import com.jme3.export.InputCapsule;
-import com.jme3.export.JmeExporter;
-import com.jme3.export.JmeImporter;
-import com.jme3.export.OutputCapsule;
 import com.jme3.material.Material;
 import com.jme3.material.RenderState;
 import com.jme3.math.Matrix4f;
@@ -45,38 +41,38 @@ import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.ViewPort;
 import com.jme3.renderer.queue.RenderQueue;
 import com.jme3.texture.FrameBuffer;
+import com.jme3.util.TempVars;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.JmeCloneable;
 
-import java.io.IOException;
-
 /**
  * Generic abstract filter that holds common implementations for the different
  * shadow filters
  *
  * @author Rémy Bouquet aka Nehon
  */
-public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> extends Filter implements Cloneable, JmeCloneable {
+public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> extends Filter implements JmeCloneable {
 
     protected T shadowRenderer;
     protected ViewPort viewPort;
 
+    private final Vector4f tempVec4 = new Vector4f();
+    private final Matrix4f tempMat4 = new Matrix4f();
+
     /**
      * For serialization only. Do not use.
      */
     protected AbstractShadowFilter() {
     }
-    
+
     /**
-     * Abstract class constructor
+     * Creates an AbstractShadowFilter. Subclasses invoke this constructor.
      *
-     * @param manager the application asset manager
-     * @param shadowMapSize the size of the rendered shadowmaps (512,1024,2048,
-     * etc...)
-     * @param shadowRenderer the shadowRenderer to use for this Filter
+     * @param assetManager The application's asset manager.
+     * @param shadowMapSize The size of the rendered shadow maps (e.g., 512, 1024, 2048).
+     * @param shadowRenderer The shadowRenderer to use for this Filter
      */
-    @SuppressWarnings("all")
-    protected AbstractShadowFilter(AssetManager manager, int shadowMapSize, T shadowRenderer) {
+    protected AbstractShadowFilter(AssetManager assetManager, int shadowMapSize, T shadowRenderer) {
         super("Post Shadow");
         this.shadowRenderer = shadowRenderer;
         // this is legacy setting for shadows with backface shadows
@@ -93,18 +89,21 @@ public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> ext
         return true;
     }
 
+    /**
+     * @deprecated Use {@link #getMaterial()} instead.
+     * @return The Material used by this filter.
+     */
+    @Deprecated
     public Material getShadowMaterial() {       
         return material;
     }
 
-    Vector4f tmpv = new Vector4f();
-
     @Override
     protected void preFrame(float tpf) {
         shadowRenderer.preFrame(tpf);
-        material.setMatrix4("ViewProjectionMatrixInverse", viewPort.getCamera().getViewProjectionMatrix().invert());
         Matrix4f m = viewPort.getCamera().getViewProjectionMatrix();
-        material.setVector4("ViewProjectionMatrixRow2", tmpv.set(m.m20, m.m21, m.m22, m.m23));
+        material.setMatrix4("ViewProjectionMatrixInverse", tempMat4.set(m).invertLocal());
+        material.setVector4("ViewProjectionMatrixRow2", tempVec4.set(m.m20, m.m21, m.m22, m.m23));
     }
 
     @Override
@@ -337,15 +336,4 @@ public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> ext
         shadowRenderer.setPostShadowMaterial(material);
     }
 
-    @Override
-    public void write(JmeExporter ex) throws IOException {
-        super.write(ex);
-        OutputCapsule oc = ex.getCapsule(this);
-    }
-
-    @Override
-    public void read(JmeImporter im) throws IOException {
-        super.read(im);
-        InputCapsule ic = im.getCapsule(this);
-    }
 }

+ 23 - 0
jme3-core/src/main/java/com/jme3/system/AppSettings.java

@@ -299,6 +299,7 @@ public final class AppSettings extends HashMap<String, Object> {
         defaults.put("UseRetinaFrameBuffer", false);
         defaults.put("WindowYPosition", 0);
         defaults.put("WindowXPosition", 0);
+        defaults.put("X11PlatformPreferred", false);
         //  defaults.put("Icons", null);
     }
 
@@ -1587,5 +1588,27 @@ public final class AppSettings extends HashMap<String, Object> {
             }
             logger.log(Level.INFO, sb.toString());
         }
+    /**
+     * Sets the preferred native platform for creating the GL context on Linux distributions.
+     * <p>
+     * This setting is relevant for Linux distributions or derivatives that utilize a Wayland session alongside an X11 via the XWayland bridge.
+     * Enabling this option allows the use of GLX for window positioning and/or icon configuration.
+     *
+     * @param preferred true to prefer GLX (native X11) for the GL context, false to prefer EGL (native Wayland).
+     */
+    public void setX11PlatformPreferred(boolean preferred) {
+        putBoolean("X11PlatformPreferred", preferred);
+    }
+    
+    /**
+     * Determines which native platform is preferred for GL context creation on Linux distributions.
+     * <p>
+     * This setting is only valid on Linux distributions or derivatives that support Wayland,
+     * and it indicates whether GLX (native X11) or EGL (native Wayland) is enabled for the GL context.
+     *
+     * @return true if GLX is preferred, otherwise false if EGL is preferred (native Wayland).
+     */
+    public boolean isX11PlatformPreferred() {
+        return getBoolean("X11PlatformPreferred");
     }
 }

+ 38 - 0
jme3-core/src/main/resources/Common/MatDefs/Misc/Dashed.j3md

@@ -0,0 +1,38 @@
+MaterialDef Dashed {
+    MaterialParameters {
+        Float DashSize
+        Vector4 Color
+    }
+    Technique {
+        WorldParameters {
+            WorldViewProjectionMatrix
+        }
+        VertexShaderNodes {
+            ShaderNode CommonVert {
+                Definition : CommonVert : Common/MatDefs/ShaderNodes/Common/CommonVert.j3sn
+                InputMappings {
+                    worldViewProjectionMatrix = WorldParam.WorldViewProjectionMatrix
+                    modelPosition = Global.position.xyz
+                    texCoord1 = Attr.inTexCoord
+                    vertColor = Attr.inColor
+                }
+                OutputMappings {
+                    Global.position = projPosition
+                }
+            }
+        }
+        FragmentShaderNodes {
+            ShaderNode Dashed {
+                Definition : Dashed : Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn
+                InputMappings {
+                    texCoord = CommonVert.texCoord1
+                    inColor = MatParam.Color
+                    size = MatParam.DashSize
+                }
+                OutputMappings {
+                    Global.color = outColor
+                }
+            }
+        }
+    }
+}

+ 23 - 0
jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn

@@ -0,0 +1,23 @@
+ShaderNodeDefinitions{ 
+    ShaderNodeDefinition Dashed {      
+        Type: Fragment
+
+        Shader GLSL100: Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag
+        
+        Documentation{
+            Renders dashed lines            
+            @input vec2 texCoord The texture coordinates
+            @input float size the size of the dashes
+            @input vec4 inColor the color of the fragment so far
+            @outColor vec4 color the output color
+        }
+        Input {
+            vec2 texCoord
+            vec4 inColor
+            float size
+        }
+        Output {
+            vec4 outColor
+        }
+    }
+}

+ 9 - 0
jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag

@@ -0,0 +1,9 @@
+void main(){
+    //@input vec2 texCoord The texture coordinates
+    //@input float size the size of the dashes
+    //@output vec4 color the output color
+
+    //insert glsl code here
+    outColor = inColor;
+    outColor.a = step(1.0 - size, texCoord.x);
+}

BIN
jme3-core/src/main/resources/Common/Textures/lightbulb32.png


+ 153 - 72
jme3-core/src/plugins/java/com/jme3/audio/plugins/WAVLoader.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -47,39 +47,62 @@ import java.nio.ByteBuffer;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
+/**
+ * An {@code AssetLoader} for loading WAV audio files.
+ * This loader supports PCM (Pulse Code Modulation) WAV files,
+ * both as in-memory {@link AudioBuffer}s and streaming {@link AudioStream}s.
+ * It handles 8-bit and 16-bit audio formats.
+ *
+ * <p>The WAV file format consists of chunks. This loader specifically parses
+ * the 'RIFF', 'WAVE', 'fmt ', and 'data' chunks.
+ */
 public class WAVLoader implements AssetLoader {
 
     private static final Logger logger = Logger.getLogger(WAVLoader.class.getName());
 
-    // all these are in big endian
+    // RIFF chunk identifiers (Big-Endian representation of ASCII characters)
     private static final int i_RIFF = 0x46464952;
     private static final int i_WAVE = 0x45564157;
     private static final int i_fmt  = 0x20746D66;
     private static final int i_data = 0x61746164;
 
-    private boolean readStream = false;
-
-    private AudioBuffer audioBuffer;
-    private AudioStream audioStream;
-    private AudioData audioData;
+    /**
+     * The number of bytes per second for the audio data, calculated from the WAV header.
+     * Used to determine the duration of the audio.
+     */
     private int bytesPerSec;
+    /**
+     * The duration of the audio in seconds.
+     */
     private float duration;
-
+    /**
+     * The input stream for reading the WAV file data.
+     */
     private ResettableInputStream in;
-    private int inOffset = 0;
-    
+
+    /**
+     * A custom {@link InputStream} extension that handles little-endian byte
+     * reading and provides seek capabilities for streaming audio by reopening
+     * and skipping the input stream.
+     */
     private static class ResettableInputStream extends LittleEndien implements SeekableStream {
         
-        final private AssetInfo info;
+        private final AssetInfo info;
         private int resetOffset = 0;
         
         public ResettableInputStream(AssetInfo info, InputStream in) {
             super(in);
             this.info = info;
         }
-        
-        public void setResetOffset(int resetOffset) {
-            this.resetOffset = resetOffset;
+
+        /**
+         * Sets the offset from the beginning of the file to reset the stream to.
+         * This is typically the start of the audio data chunk.
+         *
+         * @param offset The byte offset to reset to.
+         */
+        public void setResetOffset(int offset) {
+            this.resetOffset = offset;
         }
 
         @Override
@@ -95,122 +118,170 @@ public class WAVLoader implements AssetLoader {
                 // Resource could have gotten lost, etc.
                 try {
                     newStream.close();
-                } catch (IOException ex2) {
+                } catch (IOException ignored) {
                 }
                 throw new RuntimeException(ex);
             }
         }
     }
 
-    private void readFormatChunk(int size) throws IOException{
+    /**
+     * Reads and parses the 'fmt ' (format) chunk of the WAV file.
+     * This chunk contains information about the audio format such as
+     * compression, channels, sample rate, bits per sample, etc.
+     *
+     * @param chunkSize The size of the 'fmt ' chunk in bytes.
+     * @param audioData The {@link AudioData} object to set the format information on.
+     * @throws IOException if the file is not a supported PCM WAV, or if format
+     * parameters are invalid.
+     */
+    private void readFormatChunk(int chunkSize, AudioData audioData) throws IOException {
         // if other compressions are supported, size doesn't have to be 16
 //        if (size != 16)
 //            logger.warning("Expected size of format chunk to be 16");
 
         int compression = in.readShort();
-        if (compression != 1){
+        if (compression != 1) { // 1 = PCM (Pulse Code Modulation)
             throw new IOException("WAV Loader only supports PCM wave files");
         }
 
-        int channels = in.readShort();
+        int numChannels = in.readShort();
         int sampleRate = in.readInt();
+        bytesPerSec = in.readInt(); // Average bytes per second
 
-        bytesPerSec = in.readInt(); // used to calculate duration
-
-        int bytesPerSample = in.readShort();
+        int bytesPerSample = in.readShort(); // Bytes per sample block (channels * bytesPerSample)
         int bitsPerSample = in.readShort();
 
-        int expectedBytesPerSec = (bitsPerSample * channels * sampleRate) / 8;
-        if (expectedBytesPerSec != bytesPerSec){
+        int expectedBytesPerSec = (bitsPerSample * numChannels * sampleRate) / 8;
+        if (expectedBytesPerSec != bytesPerSec) {
             logger.log(Level.WARNING, "Expected {0} bytes per second, got {1}",
                     new Object[]{expectedBytesPerSec, bytesPerSec});
         }
-        
+
         if (bitsPerSample != 8 && bitsPerSample != 16)
             throw new IOException("Only 8 and 16 bits per sample are supported!");
 
-        if ( (bitsPerSample / 8) * channels != bytesPerSample)
+        if ((bitsPerSample / 8) * numChannels != bytesPerSample)
             throw new IOException("Invalid bytes per sample value");
 
         if (bytesPerSample * sampleRate != bytesPerSec)
             throw new IOException("Invalid bytes per second value");
 
-        audioData.setupFormat(channels, bitsPerSample, sampleRate);
+        audioData.setupFormat(numChannels, bitsPerSample, sampleRate);
 
-        int remaining = size - 16;
-        if (remaining > 0){
-            in.skipBytes(remaining);
+        // Skip any extra parameters in the format chunk (e.g., for non-PCM formats)
+        int remainingChunkBytes = chunkSize - 16;
+        if (remainingChunkBytes > 0) {
+            in.skipBytes(remainingChunkBytes);
         }
     }
 
-    private void readDataChunkForBuffer(int len) throws IOException {
-        ByteBuffer data = BufferUtils.createByteBuffer(len);
-        byte[] buf = new byte[512];
+    /**
+     * Reads the 'data' chunk for an {@link AudioBuffer}. This involves loading
+     * the entire audio data into a {@link ByteBuffer} in memory.
+     *
+     * @param dataChunkSize The size of the 'data' chunk in bytes.
+     * @param audioBuffer   The {@link AudioBuffer} to update with the loaded data.
+     * @throws IOException if an error occurs while reading the data.
+     */
+    private void readDataChunkForBuffer(int dataChunkSize, AudioBuffer audioBuffer) throws IOException {
+        ByteBuffer data = BufferUtils.createByteBuffer(dataChunkSize);
+        byte[] buf = new byte[1024]; // Use a larger buffer for efficiency
         int read = 0;
-        while ( (read = in.read(buf)) > 0){
-            data.put(buf, 0, Math.min(read, data.remaining()) );
+        while ((read = in.read(buf)) > 0) {
+            data.put(buf, 0, Math.min(read, data.remaining()));
         }
         data.flip();
         audioBuffer.updateData(data);
         in.close();
     }
 
-    private void readDataChunkForStream(int offset, int len) throws IOException {
-        in.setResetOffset(offset);
+    /**
+     * Configures the {@link AudioStream} to stream data from the 'data' chunk.
+     * This involves setting the reset offset for seeking and passing the
+     * input stream and duration to the {@link AudioStream}.
+     *
+     * @param dataChunkOffset The byte offset from the start of the file where the 'data' chunk begins.
+     * @param dataChunkSize   The size of the 'data' chunk in bytes.
+     * @param audioStream     The {@link AudioStream} to configure.
+     */
+    private void readDataChunkForStream(int dataChunkOffset, int dataChunkSize, AudioStream audioStream) {
+        in.setResetOffset(dataChunkOffset);
         audioStream.updateData(in, duration);
     }
 
-    private AudioData load(AssetInfo info, InputStream inputStream, boolean stream) throws IOException{
+    /**
+     * Main loading logic for WAV files. This method parses the RIFF, WAVE, fmt,
+     * and data chunks to extract audio information and data.
+     *
+     * @param info        The {@link AssetInfo} for the WAV file.
+     * @param inputStream The initial {@link InputStream} opened for the asset.
+     * @param stream      A boolean indicating whether the audio should be loaded
+     *                    as a stream (true) or an in-memory buffer (false).
+     * @return The loaded {@link AudioData} (either {@link AudioBuffer} or {@link AudioStream}).
+     * @throws IOException if the file is not a valid WAV, or if any I/O error occurs.
+     */
+    private AudioData load(AssetInfo info, InputStream inputStream, boolean stream) throws IOException {
         this.in = new ResettableInputStream(info, inputStream);
-        inOffset = 0;
-        
-        int sig = in.readInt();
-        if (sig != i_RIFF)
+        int inOffset = 0;
+
+        // Read RIFF chunk
+        int riffId = in.readInt();
+        if (riffId != i_RIFF) {
             throw new IOException("File is not a WAVE file");
-        
-        // skip size
+        }
+
+        // Skip RIFF chunk size
         in.readInt();
-        if (in.readInt() != i_WAVE)
+
+        int waveId = in.readInt();
+        if (waveId != i_WAVE)
             throw new IOException("WAVE File does not contain audio");
 
-        inOffset += 4 * 3;
-        
-        readStream = stream;
-        if (readStream){
+        inOffset += 4 * 3; // RIFF_ID + ChunkSize + WAVE_ID
+
+        AudioData audioData;
+        AudioBuffer audioBuffer = null;
+        AudioStream audioStream = null;
+
+        if (stream) {
             audioStream = new AudioStream();
             audioData = audioStream;
-        }else{
+        } else {
             audioBuffer = new AudioBuffer();
             audioData = audioBuffer;
         }
 
         while (true) {
-            int type = in.readInt();
-            int len = in.readInt();
-            
-            inOffset += 4 * 2;
+            int chunkType = in.readInt();
+            int chunkSize = in.readInt();
+
+            inOffset += 4 * 2; // ChunkType + ChunkSize
 
-            switch (type) {
+            switch (chunkType) {
                 case i_fmt:
-                    readFormatChunk(len);
-                    inOffset += len;
+                    readFormatChunk(chunkSize, audioData);
+                    inOffset += chunkSize;
                     break;
                 case i_data:
                     // Compute duration based on data chunk size
-                    duration = len / bytesPerSec;
+                    duration = (float) (chunkSize / bytesPerSec);
 
-                    if (readStream) {
-                        readDataChunkForStream(inOffset, len);
+                    if (stream) {
+                        readDataChunkForStream(inOffset, chunkSize, audioStream);
                     } else {
-                        readDataChunkForBuffer(len);
+                        readDataChunkForBuffer(chunkSize, audioBuffer);
                     }
                     return audioData;
                 default:
-                    int skipped = in.skipBytes(len);
-                    if (skipped <= 0) {
+                    // Skip unknown chunks
+                    int skippedBytes = in.skipBytes(chunkSize);
+                    if (skippedBytes <= 0) {
+                        logger.log(Level.WARNING, "Reached end of stream prematurely while skipping unknown chunk of size {0}. Asset: {1}",
+                                new Object[]{chunkSize, info.getKey().getName()});
                         return null;
                     }
-                    inOffset += skipped;
+                    inOffset += skippedBytes;
                     break;
             }
         }
@@ -218,18 +289,28 @@ public class WAVLoader implements AssetLoader {
     
     @Override
     public Object load(AssetInfo info) throws IOException {
-        AudioData data;
-        InputStream inputStream = null;
+        InputStream is = null;
         try {
-            inputStream = info.openStream();
-            data = load(info, inputStream, ((AudioKey)info.getKey()).isStream());
-            if (data instanceof AudioStream){
-                inputStream = null;
+            is = info.openStream();
+            boolean streamAudio = ((AudioKey) info.getKey()).isStream();
+            AudioData loadedData = load(info, is, streamAudio);
+
+            // If it's an AudioStream, the internal inputStream is managed by the stream itself
+            // and should not be closed here.
+            if (loadedData instanceof AudioStream) {
+                // Prevent closing in finally block
+                is = null;
             }
-            return data;
+            return loadedData;
         } finally {
-            if (inputStream != null){
-                inputStream.close();
+            // Nullify/reset instance variables to ensure the loader instance is clean
+            // for the next load operation.
+            in = null;
+            bytesPerSec = 0;
+            duration = 0.0f;
+
+            if (is != null) {
+                is.close();
             }
         }
     }

+ 7 - 9
jme3-core/src/plugins/java/com/jme3/material/plugins/ShaderNodeDefinitionLoader.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2018 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -38,13 +38,14 @@ import com.jme3.asset.AssetLoader;
 import com.jme3.asset.ShaderNodeDefinitionKey;
 import com.jme3.util.blockparser.BlockLanguageParser;
 import com.jme3.util.blockparser.Statement;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
 
 /**
  * ShaderNodeDefinition file loader (.j3sn)
- *
+ * <p>
  * a j3sn file is a block style file like j3md or j3m. It must contain one
  * ShaderNodeDefinition{} block that contains several ShaderNodeDefinition{}
  * blocks
@@ -53,16 +54,14 @@ import java.util.List;
  */
 public class ShaderNodeDefinitionLoader implements AssetLoader {
 
-    private ShaderNodeLoaderDelegate loaderDelegate;
-
     @Override
     public Object load(AssetInfo assetInfo) throws IOException {
-        AssetKey k = assetInfo.getKey();
-        if (!(k instanceof ShaderNodeDefinitionKey)) {
+        AssetKey<?> assetKey = assetInfo.getKey();
+        if (!(assetKey instanceof ShaderNodeDefinitionKey)) {
             throw new IOException("ShaderNodeDefinition file must be loaded via ShaderNodeDefinitionKey");
         }
-        ShaderNodeDefinitionKey key = (ShaderNodeDefinitionKey) k;
-        loaderDelegate = new ShaderNodeLoaderDelegate();
+        ShaderNodeDefinitionKey key = (ShaderNodeDefinitionKey) assetKey;
+        ShaderNodeLoaderDelegate loaderDelegate = new ShaderNodeLoaderDelegate();
 
         InputStream in = assetInfo.openStream();
         List<Statement> roots = BlockLanguageParser.parse(in);
@@ -80,6 +79,5 @@ public class ShaderNodeDefinitionLoader implements AssetLoader {
         }
 
         return loaderDelegate.readNodesDefinitions(roots.get(0).getContents(), key);
-
     }
 }

+ 30 - 0
jme3-core/src/test/java/com/jme3/math/ColorRGBATest.java

@@ -0,0 +1,30 @@
+package com.jme3.math;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * @author capdevon
+ */
+public class ColorRGBATest {
+
+    @Test
+    public void testIntColor() {
+        ColorRGBA color = new ColorRGBA(1.0f, 0.2f, 0.6f, 0.8f);
+
+        int rgba = color.asIntRGBA();
+        int abgr = color.asIntABGR();
+        int argb = color.asIntARGB();
+
+        Assert.assertEquals(-13395508, rgba);
+        Assert.assertEquals(-862374913, abgr);
+        Assert.assertEquals(-855690343, argb);
+
+        ColorRGBA copy = new ColorRGBA();
+
+        Assert.assertEquals(color, copy.fromIntRGBA(rgba));
+        Assert.assertEquals(color, copy.fromIntABGR(abgr));
+        Assert.assertEquals(color, copy.fromIntARGB(argb));
+    }
+
+}

+ 79 - 69
jme3-effects/src/main/java/com/jme3/post/ssao/SSAOFilter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -40,7 +40,6 @@ import com.jme3.material.Material;
 import com.jme3.math.Vector2f;
 import com.jme3.math.Vector3f;
 import com.jme3.post.Filter;
-import com.jme3.post.Filter.Pass;
 import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.Renderer;
 import com.jme3.renderer.ViewPort;
@@ -62,23 +61,22 @@ import java.util.ArrayList;
 public class SSAOFilter extends Filter {
 
     private Pass normalPass;
-    private Vector3f frustumCorner;
-    private Vector2f frustumNearFar;
-    private Vector2f[] samples = {new Vector2f(1.0f, 0.0f), new Vector2f(-1.0f, 0.0f), new Vector2f(0.0f, 1.0f), new Vector2f(0.0f, -1.0f)};
+    private final Vector2f[] samples = {
+            new Vector2f(1.0f, 0.0f),
+            new Vector2f(-1.0f, 0.0f),
+            new Vector2f(0.0f, 1.0f),
+            new Vector2f(0.0f, -1.0f)
+    };
     private float sampleRadius = 5.1f;
     private float intensity = 1.5f;
     private float scale = 0.2f;
     private float bias = 0.1f;
+    private boolean approximateNormals = false;
     private boolean useOnlyAo = false;
     private boolean useAo = true;
     private Material ssaoMat;
-    private Pass ssaoPass;
-//    private Material downSampleMat;
-//    private Pass downSamplePass;
-    private float downSampleFactor = 1f;
     private RenderManager renderManager;
     private ViewPort viewPort;
-    private boolean approximateNormals = false;
 
     /**
      * Create a Screen Space Ambient Occlusion Filter
@@ -89,10 +87,11 @@ public class SSAOFilter extends Filter {
 
     /**
      * Create a Screen Space Ambient Occlusion Filter
+     *
      * @param sampleRadius The radius of the area where random samples will be picked. default 5.1f
-     * @param intensity intensity of the resulting AO. default 1.2f
-     * @param scale distance between occluders and occludee. default 0.2f
-     * @param bias the width of the occlusion cone considered by the occludee. default 0.1f
+     * @param intensity    intensity of the resulting AO. default 1.5f
+     * @param scale        distance between occluders and occludee. default 0.2f
+     * @param bias         the width of the occlusion cone considered by the occludee. default 0.1f
      */
     public SSAOFilter(float sampleRadius, float intensity, float scale, float bias) {
         this();
@@ -126,38 +125,32 @@ public class SSAOFilter extends Filter {
     }
 
     @Override
-    protected void initFilter(AssetManager manager, RenderManager renderManager, ViewPort vp, int w, int h) {
+    protected void initFilter(AssetManager assetManager, RenderManager renderManager, ViewPort vp, int w, int h) {
         this.renderManager = renderManager;
         this.viewPort = vp;
         int screenWidth = w;
         int screenHeight = h;
+        float downSampleFactor = 1f;
         postRenderPasses = new ArrayList<Pass>();
 
         normalPass = new Pass();
         normalPass.init(renderManager.getRenderer(), (int) (screenWidth / downSampleFactor), (int) (screenHeight / downSampleFactor), Format.RGBA8, Format.Depth);
 
-
-        frustumNearFar = new Vector2f();
-
+        Vector2f frustumNearFar = new Vector2f();
         float farY = (vp.getCamera().getFrustumTop() / vp.getCamera().getFrustumNear()) * vp.getCamera().getFrustumFar();
         float farX = farY * (screenWidth / (float) screenHeight);
-        frustumCorner = new Vector3f(farX, farY, vp.getCamera().getFrustumFar());
+        Vector3f frustumCorner = new Vector3f(farX, farY, vp.getCamera().getFrustumFar());
         frustumNearFar.x = vp.getCamera().getFrustumNear();
         frustumNearFar.y = vp.getCamera().getFrustumFar();
 
-
-
-
-
         //ssao Pass
-        ssaoMat = new Material(manager, "Common/MatDefs/SSAO/ssao.j3md");
+        ssaoMat = new Material(assetManager, "Common/MatDefs/SSAO/ssao.j3md");
         ssaoMat.setTexture("Normals", normalPass.getRenderedTexture());
-        Texture random = manager.loadTexture("Common/MatDefs/SSAO/Textures/random.png");
+        Texture random = assetManager.loadTexture("Common/MatDefs/SSAO/Textures/random.png");
         random.setWrap(Texture.WrapMode.Repeat);
         ssaoMat.setTexture("RandomMap", random);
 
-        ssaoPass = new Pass("SSAO pass") {
-
+        Pass ssaoPass = new Pass("SSAO pass") {
             @Override
             public boolean requiresDepthAsTexture() {
                 return true;
@@ -168,18 +161,18 @@ public class SSAOFilter extends Filter {
 //        ssaoPass.getRenderedTexture().setMinFilter(Texture.MinFilter.Trilinear);
 //        ssaoPass.getRenderedTexture().setMagFilter(Texture.MagFilter.Bilinear);
         postRenderPasses.add(ssaoPass);
-        material = new Material(manager, "Common/MatDefs/SSAO/ssaoBlur.j3md");
+        material = new Material(assetManager, "Common/MatDefs/SSAO/ssaoBlur.j3md");
         material.setTexture("SSAOMap", ssaoPass.getRenderedTexture());
+        material.setVector2("FrustumNearFar", frustumNearFar);
+        material.setBoolean("UseAo", useAo);
+        material.setBoolean("UseOnlyAo", useOnlyAo);
 
         ssaoMat.setVector3("FrustumCorner", frustumCorner);
         ssaoMat.setFloat("SampleRadius", sampleRadius);
         ssaoMat.setFloat("Intensity", intensity);
         ssaoMat.setFloat("Scale", scale);
         ssaoMat.setFloat("Bias", bias);
-        material.setBoolean("UseAo", useAo);
-        material.setBoolean("UseOnlyAo", useOnlyAo);
         ssaoMat.setVector2("FrustumNearFar", frustumNearFar);
-        material.setVector2("FrustumNearFar", frustumNearFar);
         ssaoMat.setParam("Samples", VarType.Vector2Array, samples);
         ssaoMat.setBoolean("ApproximateNormals", approximateNormals);
 
@@ -189,7 +182,6 @@ public class SSAOFilter extends Filter {
         float blurScale = 2f;
         material.setFloat("XScale", blurScale * xScale);
         material.setFloat("YScale", blurScale * yScale);
-
     }
 
     @Override
@@ -198,18 +190,20 @@ public class SSAOFilter extends Filter {
     }    
     
     /**
-     * Return the bias<br>
-     * see {@link  #setBias(float bias)}
-     * @return  bias
+     * Returns the bias value used in the SSAO calculation.
+     *
+     * @return The bias value.
+     * @see #setBias(float)
      */
     public float getBias() {
         return bias;
     }
 
     /**
-     * Sets the width of the occlusion cone considered by the occludee default is 0.1f
+     * Sets the width of the occlusion cone considered by the occludee.
+     * A higher bias means a wider cone, resulting in less self-occlusion.
      *
-     * @param bias the desired width (default=0.1)
+     * @param bias The desired bias value (default: 0.1f).
      */
     public void setBias(float bias) {
         this.bias = bias;
@@ -219,62 +213,65 @@ public class SSAOFilter extends Filter {
     }
 
     /**
-     * returns the ambient occlusion intensity
-     * @return intensity
+     * Returns the ambient occlusion intensity.
+     *
+     * @return The intensity value.
      */
     public float getIntensity() {
         return intensity;
     }
 
     /**
-     * Sets the Ambient occlusion intensity default is 1.5
+     * Sets the ambient occlusion intensity. A higher intensity makes the ambient
+     * occlusion effect more pronounced.
      *
-     * @param intensity the desired intensity (default=1.5)
+     * @param intensity The desired intensity (default: 1.5f).
      */
     public void setIntensity(float intensity) {
         this.intensity = intensity;
         if (ssaoMat != null) {
             ssaoMat.setFloat("Intensity", intensity);
         }
-
     }
 
     /**
-     * returns the sample radius<br>
-     * see {link setSampleRadius(float sampleRadius)}
-     * @return the sample radius
+     * Returns the sample radius used in the SSAO calculation.
+     *
+     * @return The sample radius.
+     * @see #setSampleRadius(float)
      */
     public float getSampleRadius() {
         return sampleRadius;
     }
 
     /**
-     * Sets the radius of the area where random samples will be picked default 5.1f 
+     * Sets the radius of the area where random samples will be picked for SSAO.
+     * A larger radius considers more distant occluders.
      *
-     * @param sampleRadius the desired radius (default=5.1)
+     * @param sampleRadius The desired radius (default: 5.1f).
      */
     public void setSampleRadius(float sampleRadius) {
         this.sampleRadius = sampleRadius;
         if (ssaoMat != null) {
             ssaoMat.setFloat("SampleRadius", sampleRadius);
         }
-
     }
 
     /**
-     * returns the scale<br>
-     * see {@link #setScale(float scale)}
-     * @return scale
+     * Returns the scale value used in the SSAO calculation.
+     *
+     * @return The scale value.
+     * @see #setScale(float)
      */
     public float getScale() {
         return scale;
     }
 
     /**
-     * 
-     * Returns the distance between occluders and occludee. default 0.2f
+     * Sets the distance between occluders and occludee for SSAO.
+     * This essentially controls the "thickness" of the ambient occlusion.
      *
-     * @param scale the desired distance (default=0.2)
+     * @param scale The desired distance (default: 0.2f).
      */
     public void setScale(float scale) {
         this.scale = scale;
@@ -284,7 +281,30 @@ public class SSAOFilter extends Filter {
     }
 
     /**
-     * debugging only , will be removed
+     * Sets whether to use approximate normals for the SSAO calculation.
+     * If `true`, normals are derived from the depth buffer. If `false`, a separate
+     * normal pass is rendered.
+     *
+     * @param approximateNormals `true` to use approximate normals, `false` to use a normal pass.
+     */
+    public void setApproximateNormals(boolean approximateNormals) {
+        this.approximateNormals = approximateNormals;
+        if (ssaoMat != null) {
+            ssaoMat.setBoolean("ApproximateNormals", approximateNormals);
+        }
+    }
+
+    /**
+     * Checks if approximate normals are being used for SSAO calculation.
+     *
+     * @return `true` if approximate normals are used, `false` otherwise.
+     */
+    public boolean isApproximateNormals() {
+        return approximateNormals;
+    }
+
+    /**
+     * debugging only, will be removed
      * @return true if using ambient occlusion
      */
     public boolean isUseAo() {
@@ -292,7 +312,7 @@ public class SSAOFilter extends Filter {
     }
 
     /**
-     * debugging only , will be removed
+     * debugging only, will be removed
      *
      * @param useAo true to enable, false to disable (default=true)
      */
@@ -301,22 +321,10 @@ public class SSAOFilter extends Filter {
         if (material != null) {
             material.setBoolean("UseAo", useAo);
         }
-
-    }
-
-    public void setApproximateNormals(boolean approximateNormals) {
-        this.approximateNormals = approximateNormals;
-        if (ssaoMat != null) {
-            ssaoMat.setBoolean("ApproximateNormals", approximateNormals);
-        }
-    }
-
-    public boolean isApproximateNormals() {
-        return approximateNormals;
     }
 
     /**
-     * debugging only , will be removed
+     * debugging only, will be removed
      * @return useOnlyAo
      */
     public boolean isUseOnlyAo() {
@@ -324,7 +332,7 @@ public class SSAOFilter extends Filter {
     }
 
     /**
-     * debugging only , will be removed
+     * debugging only, will be removed
      *
      * @param useOnlyAo true to enable, false to disable (default=false)
      */
@@ -343,6 +351,7 @@ public class SSAOFilter extends Filter {
         oc.write(intensity, "intensity", 1.5f);
         oc.write(scale, "scale", 0.2f);
         oc.write(bias, "bias", 0.1f);
+        oc.write(approximateNormals, "approximateNormals", false);
     }
 
     @Override
@@ -353,5 +362,6 @@ public class SSAOFilter extends Filter {
         intensity = ic.readFloat("intensity", 1.5f);
         scale = ic.readFloat("scale", 0.2f);
         bias = ic.readFloat("bias", 0.1f);
+        approximateNormals = ic.readBoolean("approximateNormals", false);
     }
 }

+ 63 - 0
jme3-effects/src/test/java/com/jme3/post/filters/SSAOFilterTest.java

@@ -0,0 +1,63 @@
+package com.jme3.post.filters;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.DesktopAssetManager;
+import com.jme3.export.binary.BinaryExporter;
+import com.jme3.post.ssao.SSAOFilter;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Automated tests for the {@code SSAOFilter} class.
+ *
+ * @author capdevon
+ */
+public class SSAOFilterTest {
+
+    /**
+     * Tests serialization and de-serialization of an {@code SSAOFilter}.
+     */
+    @Test
+    public void testSaveAndLoad() {
+        SSAOFilter filter = new SSAOFilter();
+
+        // Verify the default parameter values:
+        verifyDefaults(filter);
+
+        // Set parameters to new values:
+        filter.setEnabled(false);
+        filter.setSampleRadius(4.5f);
+        filter.setIntensity(1.8f);
+        filter.setScale(0.4f);
+        filter.setBias(0.5f);
+        filter.setApproximateNormals(true);
+
+        // Create a duplicate filter using serialization:
+        AssetManager assetManager = new DesktopAssetManager();
+        SSAOFilter copy = BinaryExporter.saveAndLoad(assetManager, filter);
+
+        // Verify the parameter values of the copy:
+        Assert.assertEquals("SSAOFilter", copy.getName());
+        Assert.assertEquals(4.5f, copy.getSampleRadius(), 0f);
+        Assert.assertEquals(1.8f, copy.getIntensity(), 0f);
+        Assert.assertEquals(0.4f, copy.getScale(), 0f);
+        Assert.assertEquals(0.5f, copy.getBias(), 0f);
+        Assert.assertTrue(copy.isApproximateNormals());
+        Assert.assertFalse(copy.isEnabled());
+    }
+
+    /**
+     * Verify some default values of a newly instantiated {@code SSAOFilter}.
+     *
+     * @param filter (not null, unaffected)
+     */
+    private void verifyDefaults(SSAOFilter filter) {
+        Assert.assertEquals("SSAOFilter", filter.getName());
+        Assert.assertEquals(5.1f, filter.getSampleRadius(), 0f);
+        Assert.assertEquals(1.5f, filter.getIntensity(), 0f);
+        Assert.assertEquals(0.2f, filter.getScale(), 0f);
+        Assert.assertEquals(0.1f, filter.getBias(), 0f);
+        Assert.assertFalse(filter.isApproximateNormals());
+        Assert.assertTrue(filter.isEnabled());
+    }
+}

+ 79 - 23
jme3-examples/src/main/java/jme3test/export/TestOgreConvert.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
@@ -29,53 +29,109 @@
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
-
 package jme3test.export;
 
 import com.jme3.anim.AnimComposer;
+import com.jme3.anim.SkinningControl;
 import com.jme3.app.SimpleApplication;
 import com.jme3.export.binary.BinaryExporter;
-import com.jme3.export.binary.BinaryImporter;
+import com.jme3.font.BitmapText;
+import com.jme3.light.AmbientLight;
 import com.jme3.light.DirectionalLight;
+import com.jme3.material.MatParamOverride;
 import com.jme3.math.ColorRGBA;
 import com.jme3.math.Vector3f;
-import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
 
-import java.io.*;
-
+/**
+ * This class is a jMonkeyEngine 3 (jME3) test application designed to verify
+ * the import, export, and runtime behavior of 3D models, particularly those
+ * in or compatible with the Ogre3D format (.mesh.xml).
+ * It loads an Ogre model, saves and reloads it using jME3's binary exporter,
+ * plays an animation, and displays debugging information about its skinning
+ * and material parameters.
+ *
+ * @author capdevon
+ */
 public class TestOgreConvert extends SimpleApplication {
 
-    public static void main(String[] args){
+    public static void main(String[] args) {
         TestOgreConvert app = new TestOgreConvert();
+        app.setPauseOnLostFocus(false);
         app.start();
     }
 
+    private final StringBuilder sb = new StringBuilder();
+    private int frameCount = 0;
+    private BitmapText bmp;
+    private Spatial spCopy;
+    private SkinningControl skinningControl;
+
     @Override
     public void simpleInitApp() {
-        Spatial ogreModel = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+        configureCamera();
+        setupLights();
+
+        bmp = createLabelText(10, 20, "<placeholder>");
+
+        // Load the Ogre model (Oto.mesh.xml) from the assets
+        Spatial model = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+        // Save the loaded model to jME3's binary format and then reload it.
+        // This tests the binary serialization/deserialization process.
+        spCopy = BinaryExporter.saveAndLoad(assetManager, model);
+        spCopy.setName("Oto-Copy");
+        rootNode.attachChild(spCopy);
+
+        AnimComposer animComposer = spCopy.getControl(AnimComposer.class);
+        animComposer.setCurrentAction("Walk");
+
+        // Get the SkinningControl from the model to inspect skinning properties
+        skinningControl = spCopy.getControl(SkinningControl.class);
+    }
+
+    private void setupLights() {
+        AmbientLight al = new AmbientLight();
+        rootNode.addLight(al);
 
         DirectionalLight dl = new DirectionalLight();
-        dl.setColor(ColorRGBA.White);
-        dl.setDirection(new Vector3f(0,-1,-1).normalizeLocal());
+        dl.setDirection(new Vector3f(0, -1, -1).normalizeLocal());
         rootNode.addLight(dl);
+    }
 
-        try {
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            BinaryExporter exp = new BinaryExporter();
-            exp.save(ogreModel, baos);
+    private void configureCamera() {
+        flyCam.setDragToRotate(true);
+        flyCam.setMoveSpeed(15f);
 
-            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
-            BinaryImporter imp = new BinaryImporter();
-            imp.setAssetManager(assetManager);
-            Node ogreModelReloaded = (Node) imp.load(bais, null, null);
+        cam.setLocation(new Vector3f(0, 0, 20));
+    }
 
-            AnimComposer composer = ogreModelReloaded.getControl(AnimComposer.class);
-            composer.setCurrentAction("Walk");
+    @Override
+    public void simpleUpdate(float tpf) {
+        frameCount++;
+        if (frameCount == 10) {
+            frameCount = 0;
 
-            rootNode.attachChild(ogreModelReloaded);
-        } catch (IOException ex){
-            ex.printStackTrace();
+            sb.append("HW Skinning Preferred: ").append(skinningControl.isHardwareSkinningPreferred()).append("\n");
+            sb.append("HW Skinning Enabled: ").append(skinningControl.isHardwareSkinningUsed()).append("\n");
+            sb.append("Mesh Targets: ").append(skinningControl.getTargets().length).append("\n");
+
+            for (MatParamOverride mpo : spCopy.getLocalMatParamOverrides()) {
+                sb.append(mpo.getVarType()).append(" ");
+                sb.append(mpo.getName()).append(": ");
+                sb.append(mpo.getValue()).append("\n");
+            }
+
+            bmp.setText(sb.toString());
+            sb.setLength(0);
         }
     }
+
+    private BitmapText createLabelText(int x, int y, String text) {
+        BitmapText bmp = new BitmapText(guiFont);
+        bmp.setText(text);
+        bmp.setLocalTranslation(x, settings.getHeight() - y, 0);
+        bmp.setColor(ColorRGBA.Red);
+        guiNode.attachChild(bmp);
+        return bmp;
+    }
 }

+ 85 - 43
jme3-examples/src/main/java/jme3test/stress/TestLodGeneration.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -44,10 +44,10 @@ import com.jme3.input.ChaseCamera;
 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;
-import com.jme3.math.ColorRGBA;
 import com.jme3.math.FastMath;
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
@@ -58,44 +58,48 @@ import com.jme3.scene.VertexBuffer;
 
 import jme3tools.optimize.LodGenerator;
 
-public class TestLodGeneration extends SimpleApplication {
+public class TestLodGeneration extends SimpleApplication implements ActionListener {
 
     public static void main(String[] args) {
         TestLodGeneration app = new TestLodGeneration();
         app.start();
     }
 
-    private boolean wireFrame = false;
+    private boolean wireframe = false;
+    // Current reduction value for LOD generation (0.0 to 1.0)
     private float reductionValue = 0.0f;
     private int lodLevel = 0;
     private BitmapText hudText;
-    final private List<Geometry> listGeoms = new ArrayList<>();
-    final private ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(5);
+    private final List<Geometry> listGeoms = new ArrayList<>();
+    private final ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(5);
 
     @Override
     public void simpleInitApp() {
 
+        // --- Lighting Setup ---
         DirectionalLight dl = new DirectionalLight();
         dl.setDirection(new Vector3f(-1, -1, -1).normalizeLocal());
         rootNode.addLight(dl);
 
         AmbientLight al = new AmbientLight();
-        al.setColor(ColorRGBA.White.mult(0.6f));
         rootNode.addLight(al);
 
+        // --- Model Loading and Setup ---
         // model = (Node) assetManager.loadModel("Models/Sinbad/Sinbad.mesh.xml");
         Node model = (Node) assetManager.loadModel("Models/Jaime/Jaime.j3o");
         BoundingBox b = ((BoundingBox) model.getWorldBound());
         model.setLocalScale(1.2f / (b.getYExtent() * 2));
         // model.setLocalTranslation(0,-(b.getCenter().y - b.getYExtent())* model.getLocalScale().y, 0);
+
+        // Iterate through the model's children and collect all Geometry objects
         for (Spatial spatial : model.getChildren()) {
             if (spatial instanceof Geometry) {
                 listGeoms.add((Geometry) spatial);
             }
         }
 
-        ChaseCamera chaseCam = new ChaseCamera(cam, inputManager);
-        model.addControl(chaseCam);
+        // --- Camera Setup ---
+        ChaseCamera chaseCam = new ChaseCamera(cam, model, inputManager);
         chaseCam.setLookAtOffset(b.getCenter());
         chaseCam.setDefaultDistance(5);
         chaseCam.setMinVerticalRotation(-FastMath.HALF_PI + 0.01f);
@@ -103,11 +107,17 @@ public class TestLodGeneration extends SimpleApplication {
 
         SkinningControl skControl = model.getControl(SkinningControl.class);
         if (skControl != null) {
+            // Disable skinning control if found. This is an optimization for static LOD generation
+            // as skinning computation is not needed when generating LODs.
             skControl.setEnabled(false);
         }
 
+        // --- Initial LOD Generation ---
+        // Set initial reduction value and LOD level
         reductionValue = 0.80f;
         lodLevel = 1;
+
+        // Generate LODs for each geometry in the model
         for (final Geometry geom : listGeoms) {
             LodGenerator lodGenerator = new LodGenerator(geom);
             lodGenerator.bakeLods(LodGenerator.TriangleReductionMethod.PROPORTIONAL, reductionValue);
@@ -115,45 +125,49 @@ public class TestLodGeneration extends SimpleApplication {
         }
 
         rootNode.attachChild(model);
+        // Disable the default fly camera as we are using a chase camera
         flyCam.setEnabled(false);
 
-        guiFont = assetManager.loadFont("Interface/Fonts/Default.fnt");
+        // --- HUD Setup ---
         hudText = new BitmapText(guiFont);
-        hudText.setSize(guiFont.getCharSet().getRenderedSize());
         hudText.setText(computeNbTri() + " tris");
-        hudText.setLocalTranslation(cam.getWidth() / 2, hudText.getLineHeight(), 0);
+        hudText.setLocalTranslation(cam.getWidth() / 2f, hudText.getLineHeight(), 0);
         guiNode.attachChild(hudText);
 
-        inputManager.addListener(new ActionListener() {
-            @Override
-            public void onAction(String name, boolean isPressed, float tpf) {
-                if (isPressed) {
-                    if (name.equals("plus")) {
-                        reductionValue += 0.05f;
-                        updateLod();
-                    }
-                    if (name.equals("minus")) {
-                        reductionValue -= 0.05f;
-                        updateLod();
-                    }
-                    if (name.equals("wireFrame")) {
-                        wireFrame = !wireFrame;
-                        for (Geometry geom : listGeoms) {
-                            Material mat = geom.getMaterial();
-                            mat.getAdditionalRenderState().setWireframe(wireFrame);
-                        }
-                    }
-                }
+        // Register input mappings for user interaction
+        registerInputMappings();
+    }
+
+    @Override
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (!isPressed) return;
+
+        if (name.equals("plus")) {
+            reductionValue += 0.05f;
+            updateLod();
+
+        } else if (name.equals("minus")) {
+            reductionValue -= 0.05f;
+            updateLod();
+
+        } else if (name.equals("wireframe")) {
+            wireframe = !wireframe;
+            for (Geometry geom : listGeoms) {
+                Material mat = geom.getMaterial();
+                mat.getAdditionalRenderState().setWireframe(wireframe);
             }
-        }, "plus", "minus", "wireFrame");
+        }
+    }
 
-        inputManager.addMapping("plus", new KeyTrigger(KeyInput.KEY_ADD));
-        inputManager.addMapping("minus", new KeyTrigger(KeyInput.KEY_SUBTRACT));
-        inputManager.addMapping("wireFrame", new KeyTrigger(KeyInput.KEY_SPACE));
+    private void registerInputMappings() {
+        addMapping("plus", new KeyTrigger(KeyInput.KEY_P));
+        addMapping("minus", new KeyTrigger(KeyInput.KEY_L));
+        addMapping("wireframe", new KeyTrigger(KeyInput.KEY_SPACE));
     }
 
-    @Override
-    public void simpleUpdate(float tpf) {
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(this, mappingName);
     }
 
     @Override
@@ -163,14 +177,20 @@ public class TestLodGeneration extends SimpleApplication {
     }
 
     private void updateLod() {
+        // Clamp the reduction value between 0.0 and 1.0 to ensure it's within valid range
         reductionValue = FastMath.clamp(reductionValue, 0.0f, 1.0f);
         makeLod(LodGenerator.TriangleReductionMethod.PROPORTIONAL, reductionValue, 1);
     }
 
+    /**
+     * Computes the total number of triangles currently displayed by all geometries.
+     * @return The total number of triangles.
+     */
     private int computeNbTri() {
         int nbTri = 0;
         for (Geometry geom : listGeoms) {
             Mesh mesh = geom.getMesh();
+            // Check if the mesh has LOD levels
             if (mesh.getNumLodLevels() > 0) {
                 nbTri += mesh.getLodLevel(lodLevel).getNumElements();
             } else {
@@ -180,24 +200,46 @@ public class TestLodGeneration extends SimpleApplication {
         return nbTri;
     }
 
-    private void makeLod(final LodGenerator.TriangleReductionMethod method, final float value, final int ll) {
+    /**
+     * Generates and applies LOD levels to the geometries in a background thread.
+     *
+     * @param reductionMethod     The triangle reduction method to use (e.g., PROPORTIONAL).
+     * @param reductionPercentage The percentage of triangles to reduce (0.0 to 1.0).
+     * @param targetLodLevel      The index of the LOD level to set active after generation.
+     */
+    private void makeLod(final LodGenerator.TriangleReductionMethod reductionMethod,
+                         final float reductionPercentage, final int targetLodLevel) {
+
+        // --- Asynchronous LOD Generation ---
+        // Execute the LOD generation process in the background thread pool.
         exec.execute(new Runnable() {
             @Override
             public void run() {
                 for (final Geometry geom : listGeoms) {
                     LodGenerator lodGenerator = new LodGenerator(geom);
-                    final VertexBuffer[] lods = lodGenerator.computeLods(method, value);
+                    final VertexBuffer[] lods = lodGenerator.computeLods(reductionMethod, reductionPercentage);
 
+                    // --- JME Thread Synchronization ---
+                    // Mesh modifications and scene graph updates must be done on the main thread.
                     enqueue(new Callable<Void>() {
                         @Override
                         public Void call() throws Exception {
                             geom.getMesh().setLodLevels(lods);
+
+                            // Reset lodLevel to 0 initially
                             lodLevel = 0;
-                            if (geom.getMesh().getNumLodLevels() > ll) {
-                                lodLevel = ll;
+                            // If the generated LOD levels are more than the target, set to target LOD
+                            if (geom.getMesh().getNumLodLevels() > targetLodLevel) {
+                                lodLevel = targetLodLevel;
                             }
                             geom.setLodLevel(lodLevel);
-                            hudText.setText(computeNbTri() + " tris");
+
+                            int nbTri = computeNbTri();
+                            hudText.setText(nbTri + " tris");
+
+                            // Print debug information to the console
+                            System.out.println(geom + " lodLevel: " + lodLevel + ", numLodLevels: " + geom.getMesh().getNumLodLevels()
+                                    + ", reductionValue: " + reductionValue + ", triangles: " + nbTri);
                             return null;
                         }
                     });

+ 23 - 0
jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java

@@ -274,6 +274,21 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
                 }
         );
 
+        if (glfwPlatformSupported(GLFW_PLATFORM_WAYLAND)) {
+
+            /*
+             * Change the platform GLFW uses to enable GLX on Wayland as long as you 
+             * have XWayland (X11 compatibility)
+             */
+            if (settings.isX11PlatformPreferred() && glfwPlatformSupported(GLFW_PLATFORM_X11)) {
+                glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11);
+            }
+
+            // Disables the libdecor bar when creating a fullscreen context
+            // https://www.glfw.org/docs/latest/intro_guide.html#init_hints_wayland
+            glfwInitHint(GLFW_WAYLAND_LIBDECOR, settings.isFullscreen() ? GLFW_WAYLAND_DISABLE_LIBDECOR : GLFW_WAYLAND_PREFER_LIBDECOR);
+        }
+
         if (!glfwInit()) {
             throw new IllegalStateException("Unable to initialize GLFW");
         }
@@ -397,6 +412,14 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         );
 
         int platformId = glfwGetPlatform();
+        if (settings.isX11PlatformPreferred()) {
+            if (platformId == GLFW_PLATFORM_X11) {
+                LOGGER.log(Level.INFO, "Active X11 server for GLX management:\n * Platform: GLFW_PLATFORM_X11|XWayland ({0})", platformId);
+            } else {
+                LOGGER.log(Level.WARNING, "Can't change platform to X11 (GLX), check if you have XWayland enabled");
+            }
+        }
+        
         if (platformId != GLFW_PLATFORM_WAYLAND && !settings.isFullscreen()) {
             /*
              * in case the window positioning hints above were ignored, but not

+ 151 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/model/shape/TestBillboard.java

@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2025 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.model.shape;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.material.Material;
+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.control.BillboardControl;
+import com.jme3.scene.debug.Arrow;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.shape.Quad;
+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 test for the Billboard test.
+ * 
+ * <p>This test creates three different billboard alignments (Screen, Camera, AxialY)
+ * with different colored quads. Each billboard is positioned at a different x-coordinate
+ * and has a blue Z-axis arrow attached to it. Screenshots are taken from three different angles:
+ * front, above, and right.
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+@SuppressWarnings("OptionalGetWithoutIsPresent")
+public class TestBillboard extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+                Arguments.of("fromFront", new Vector3f(0, 1, 15)),
+                Arguments.of("fromAbove", new Vector3f(0, 15, 6)),
+                Arguments.of("fromRight", new Vector3f(-15, 10, 5))
+        );
+    }
+
+    /**
+     * A billboard test with the specified camera parameters.
+     *
+     * @param cameraPosition The position of the camera
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testBillboard(String testName, Vector3f cameraPosition, TestInfo testInfo) {
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+
+                // Set up the camera
+                simpleApplication.getCamera().setLocation(cameraPosition);
+                simpleApplication.getCamera().lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+
+                // Set background color
+                simpleApplication.getViewPort().setBackgroundColor(ColorRGBA.DarkGray);
+
+                // Create grid
+                Geometry grid = makeShape(simpleApplication, "DebugGrid", new Grid(21, 21, 2), ColorRGBA.Gray);
+                grid.center().move(0, 0, 0);
+                rootNode.attachChild(grid);
+
+                // Create billboards with different alignments
+                Node node = createBillboard(simpleApplication, BillboardControl.Alignment.Screen, ColorRGBA.Red);
+                node.setLocalTranslation(-6f, 0, 0);
+                rootNode.attachChild(node);
+
+                node = createBillboard(simpleApplication, BillboardControl.Alignment.Camera, ColorRGBA.Green);
+                node.setLocalTranslation(-2f, 0, 0);
+                rootNode.attachChild(node);
+
+                node = createBillboard(simpleApplication, BillboardControl.Alignment.AxialY, ColorRGBA.Blue);
+                node.setLocalTranslation(2f, 0, 0);
+                rootNode.attachChild(node);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+            private Node createBillboard(SimpleApplication app, BillboardControl.Alignment alignment, ColorRGBA color) {
+                Node node = new Node("Parent");
+                Quad quad = new Quad(2, 2);
+                Geometry g = makeShape(app, alignment.name(), quad, color);
+                BillboardControl bc = new BillboardControl();
+                bc.setAlignment(alignment);
+                g.addControl(bc);
+                node.attachChild(g);
+                node.attachChild(makeShape(app, "ZAxis", new Arrow(Vector3f.UNIT_Z), ColorRGBA.Blue));
+                return node;
+            }
+
+            private Geometry makeShape(SimpleApplication app, String name, Mesh shape, ColorRGBA color) {
+                Geometry geo = new Geometry(name, shape);
+                Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
+                mat.setColor("Color", color);
+                geo.setMaterial(mat);
+                return geo;
+            }
+        })
+        .setBaseImageFileName(imageName)
+        .setFramesToTakeScreenshotsOn(1)
+        .run();
+    }
+}

+ 150 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestCartoonEdge.java

@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2025 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.post;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.CartoonEdgeFilter;
+import com.jme3.renderer.Caps;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.Spatial.CullHint;
+import com.jme3.texture.Texture;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the CartoonEdge filter.
+ * 
+ * <p>This test creates a scene with a monkey head model that has a cartoon/cel-shaded effect
+ * applied to it. The CartoonEdgeFilter is used to create yellow outlines around the edges
+ * of the model, and a toon shader is applied to the model's material to create the cel-shaded look.
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestCartoonEdge extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a cartoon-shaded monkey head model.
+     */
+    @Test
+    public void testCartoonEdge() {
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+
+                simpleApplication.getViewPort().setBackgroundColor(ColorRGBA.Gray);
+
+                simpleApplication.getCamera().setLocation(new Vector3f(-1, 2, -5));
+                simpleApplication.getCamera().lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+                simpleApplication.getCamera().setFrustumFar(300);
+
+                rootNode.setCullHint(CullHint.Never);
+
+                setupLighting(rootNode);
+                
+                setupModel(simpleApplication, rootNode);
+                
+                setupFilters(simpleApplication);
+            }
+
+            private void setupFilters(SimpleApplication app) {
+                if (app.getRenderer().getCaps().contains(Caps.GLSL100)) {
+                    FilterPostProcessor fpp = new FilterPostProcessor(app.getAssetManager());
+
+                    CartoonEdgeFilter toon = new CartoonEdgeFilter();
+                    toon.setEdgeColor(ColorRGBA.Yellow);
+                    fpp.addFilter(toon);
+                    app.getViewPort().addProcessor(fpp);
+                }
+            }
+
+            private void setupLighting(Node rootNode) {
+                DirectionalLight dl = new DirectionalLight();
+                dl.setDirection(new Vector3f(-1, -1, 1).normalizeLocal());
+                dl.setColor(new ColorRGBA(2, 2, 2, 1));
+                rootNode.addLight(dl);
+            }
+
+            private void setupModel(SimpleApplication app, Node rootNode) {
+                Spatial model = app.getAssetManager().loadModel("Models/MonkeyHead/MonkeyHead.mesh.xml");
+                makeToonish(app, model);
+                model.rotate(0, FastMath.PI, 0);
+                rootNode.attachChild(model);
+            }
+
+            private void makeToonish(SimpleApplication app, Spatial spatial) {
+                if (spatial instanceof Node) {
+                    Node n = (Node) spatial;
+                    for (Spatial child : n.getChildren()) {
+                        makeToonish(app, child);
+                    }
+                } else if (spatial instanceof Geometry) {
+                    Geometry g = (Geometry) spatial;
+                    Material m = g.getMaterial();
+                    if (m.getMaterialDef().getMaterialParam("UseMaterialColors") != null) {
+                        Texture t = app.getAssetManager().loadTexture("Textures/ColorRamp/toon.png");
+                        m.setTexture("ColorRamp", t);
+                        m.setBoolean("UseMaterialColors", true);
+                        m.setColor("Specular", ColorRGBA.Black);
+                        m.setColor("Diffuse", ColorRGBA.White);
+                        m.setBoolean("VertexLighting", true);
+                    }
+                }
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+        })
+        .setFramesToTakeScreenshotsOn(1)
+        .run();
+    }
+}

+ 172 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestFog.java

@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2025 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.post;
+
+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.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.FogFilter;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Node;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Texture;
+import com.jme3.util.SkyFactory;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the Fog filter.
+ * 
+ * <p>This test creates a scene with a terrain and sky, with a fog effect applied.
+ * The fog is a light gray color and has a specific density and distance setting.
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestFog extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a fog effect.
+     */
+    @Test
+    public void testFog() {
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+
+                simpleApplication.getCamera().setLocation(new Vector3f(-34.74095f, 95.21318f, -287.4945f));
+                simpleApplication.getCamera().setRotation(new Quaternion(0.023536969f, 0.9361278f, -0.016098259f, -0.35050195f));
+
+                Node mainScene = new Node();
+
+                mainScene.attachChild(SkyFactory.createSky(simpleApplication.getAssetManager(),
+                        "Textures/Sky/Bright/BrightSky.dds", 
+                        SkyFactory.EnvMapType.CubeMap));
+                
+                createTerrain(mainScene, app.getAssetManager());
+
+                DirectionalLight sun = new DirectionalLight();
+                Vector3f lightDir = new Vector3f(-0.37352666f, -0.50444174f, -0.7784704f);
+                sun.setDirection(lightDir);
+                sun.setColor(ColorRGBA.White.clone().multLocal(2));
+                mainScene.addLight(sun);
+
+                rootNode.attachChild(mainScene);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(simpleApplication.getAssetManager());
+
+                FogFilter fog = new FogFilter();
+                fog.setFogColor(new ColorRGBA(0.9f, 0.9f, 0.9f, 1.0f));
+                fog.setFogDistance(155);
+                fog.setFogDensity(1.0f);
+                fpp.addFilter(fog);
+                simpleApplication.getViewPort().addProcessor(fpp);
+            }
+
+
+            private void createTerrain(Node rootNode, AssetManager assetManager) {
+                Material matRock = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md");
+                matRock.setBoolean("useTriPlanarMapping", false);
+                matRock.setBoolean("WardIso", true);
+                matRock.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));
+                Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
+                Texture grass = assetManager.loadTexture("Textures/Terrain/splat/grass.jpg");
+                grass.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap", grass);
+                matRock.setFloat("DiffuseMap_0_scale", 64);
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
+                dirt.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_1", dirt);
+                matRock.setFloat("DiffuseMap_1_scale", 16);
+                Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
+                rock.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_2", rock);
+                matRock.setFloat("DiffuseMap_2_scale", 128);
+                Texture normalMap0 = assetManager.loadTexture("Textures/Terrain/splat/grass_normal.jpg");
+                normalMap0.setWrap(Texture.WrapMode.Repeat);
+                Texture normalMap1 = assetManager.loadTexture("Textures/Terrain/splat/dirt_normal.png");
+                normalMap1.setWrap(Texture.WrapMode.Repeat);
+                Texture normalMap2 = assetManager.loadTexture("Textures/Terrain/splat/road_normal.png");
+                normalMap2.setWrap(Texture.WrapMode.Repeat);
+                matRock.setTexture("NormalMap", normalMap0);
+                matRock.setTexture("NormalMap_1", normalMap1);
+                matRock.setTexture("NormalMap_2", normalMap2);
+
+                AbstractHeightMap heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.25f);
+                heightmap.load();
+
+                TerrainQuad terrain = new TerrainQuad("terrain", 65, 513, heightmap.getHeightMap());
+
+                terrain.setMaterial(matRock);
+                terrain.setLocalScale(new Vector3f(5, 5, 5));
+                terrain.setLocalTranslation(new Vector3f(0, -30, 0));
+                terrain.setLocked(false); // unlock it so we can edit the height
+
+                terrain.setShadowMode(RenderQueue.ShadowMode.Receive);
+                rootNode.attachChild(terrain);
+
+            }
+
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+                System.out.println(getApplication().getCamera().getLocation());
+            }
+
+        })
+        .setFramesToTakeScreenshotsOn(1)
+        .run();
+    }
+}

+ 126 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestLightScattering.java

@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2025 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.post;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.LightScatteringFilter;
+import com.jme3.renderer.queue.RenderQueue.ShadowMode;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+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.Test;
+
+/**
+ * Screenshot test for the LightScattering filter.
+ * 
+ * <p>This test creates a scene with a terrain model and a sky, with a light scattering
+ * (god rays) effect applied. The effect simulates light rays scattering through the atmosphere
+ * from a bright light source (like the sun).
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestLightScattering extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a light scattering effect.
+     */
+    @Test
+    public void testLightScattering() {
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+
+                simpleApplication.getCamera().setLocation(new Vector3f(55.35316f, -0.27061665f, 27.092093f));
+                simpleApplication.getCamera().setRotation(new Quaternion(0.010414706f, 0.9874893f, 0.13880467f, -0.07409228f));
+
+                Material mat = simpleApplication.getAssetManager().loadMaterial("Textures/Terrain/Rocky/Rocky.j3m");
+                Spatial scene = simpleApplication.getAssetManager().loadModel("Models/Terrain/Terrain.mesh.xml");
+                MikktspaceTangentGenerator.generate(((Geometry) ((Node) scene).getChild(0)).getMesh());
+                scene.setMaterial(mat);
+                scene.setShadowMode(ShadowMode.CastAndReceive);
+                scene.setLocalScale(400);
+                scene.setLocalTranslation(0, -10, -120);
+                rootNode.attachChild(scene);
+
+                rootNode.attachChild(SkyFactory.createSky(simpleApplication.getAssetManager(),
+                        "Textures/Sky/Bright/FullskiesBlueClear03.dds", 
+                        SkyFactory.EnvMapType.CubeMap));
+
+                DirectionalLight sun = new DirectionalLight();
+                Vector3f lightDir = new Vector3f(-0.12f, -0.3729129f, 0.74847335f);
+                sun.setDirection(lightDir);
+                sun.setColor(ColorRGBA.White.clone().multLocal(2));
+                scene.addLight(sun);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(simpleApplication.getAssetManager());
+
+                Vector3f lightPos = lightDir.normalize().negate().multLocal(3000);
+                LightScatteringFilter filter = new LightScatteringFilter(lightPos);
+
+                filter.setLightDensity(1.0f);
+                filter.setBlurStart(0.02f);
+                filter.setBlurWidth(0.9f);
+                filter.setLightPosition(lightPos);
+                
+                fpp.addFilter(filter);
+                simpleApplication.getViewPort().addProcessor(fpp);
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+        })
+        .setFramesToTakeScreenshotsOn(1)
+        .run();
+    }
+}

BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromAbove_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromFront_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromRight_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestCartoonEdge.testCartoonEdge_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestFog.testFog_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestLightScattering.testLightScattering_f1.png