Browse Source

Merge branch 'jMonkeyEngine:master' into master

joliver82 5 months ago
parent
commit
0248d8e392
100 changed files with 8069 additions and 2008 deletions
  1. 68 0
      .github/actions/tools/uploadToCentral.sh
  2. 24 21
      .github/workflows/main.yml
  3. 119 0
      .github/workflows/screenshot-test-comment.yml
  4. 20 12
      common.gradle
  5. 1 1
      gradle.properties
  6. 54 7
      jme3-core/src/main/java/com/jme3/anim/MatrixJointModelTransform.java
  7. 181 150
      jme3-core/src/main/java/com/jme3/anim/SkinningControl.java
  8. 2 3
      jme3-core/src/main/java/com/jme3/animation/LoopMode.java
  9. 75 25
      jme3-core/src/main/java/com/jme3/app/state/ConstantVerifierState.java
  10. 5 1
      jme3-core/src/main/java/com/jme3/audio/AudioNode.java
  11. 152 0
      jme3-core/src/main/java/com/jme3/audio/BandPassFilter.java
  12. 153 53
      jme3-core/src/main/java/com/jme3/audio/Environment.java
  13. 23 4
      jme3-core/src/main/java/com/jme3/audio/Filter.java
  14. 131 0
      jme3-core/src/main/java/com/jme3/audio/HighPassFilter.java
  15. 105 13
      jme3-core/src/main/java/com/jme3/audio/Listener.java
  16. 72 6
      jme3-core/src/main/java/com/jme3/audio/LowPassFilter.java
  17. 436 289
      jme3-core/src/main/java/com/jme3/audio/openal/ALAudioRenderer.java
  18. 26 26
      jme3-core/src/main/java/com/jme3/audio/openal/EFX.java
  19. 51 18
      jme3-core/src/main/java/com/jme3/cinematic/events/CameraEvent.java
  20. 17 19
      jme3-core/src/main/java/com/jme3/cinematic/events/MotionEvent.java
  21. 1 1
      jme3-core/src/main/java/com/jme3/effect/ParticleEmitter.java
  22. 6 8
      jme3-core/src/main/java/com/jme3/effect/influencers/DefaultParticleInfluencer.java
  23. 1 12
      jme3-core/src/main/java/com/jme3/effect/influencers/NewtonianParticleInfluencer.java
  24. 3 3
      jme3-core/src/main/java/com/jme3/effect/influencers/ParticleInfluencer.java
  25. 56 6
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterBoxShape.java
  26. 39 6
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterPointShape.java
  27. 5 5
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterShape.java
  28. 54 2
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterSphereShape.java
  29. 3 2
      jme3-core/src/main/java/com/jme3/environment/EnvironmentProbeControl.java
  30. 3 1
      jme3-core/src/main/java/com/jme3/environment/FastLightProbeFactory.java
  31. 4 2
      jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java
  32. 1 0
      jme3-core/src/main/java/com/jme3/environment/baker/IBLHybridEnvBakerLight.java
  33. 167 0
      jme3-core/src/main/java/com/jme3/environment/util/Circle.java
  34. 359 86
      jme3-core/src/main/java/com/jme3/environment/util/LightsDebugState.java
  35. 136 123
      jme3-core/src/main/java/com/jme3/input/FlyByCamera.java
  36. 5 1
      jme3-core/src/main/java/com/jme3/input/JoystickButton.java
  37. 10 1
      jme3-core/src/main/java/com/jme3/light/AmbientLight.java
  38. 11 6
      jme3-core/src/main/java/com/jme3/light/DirectionalLight.java
  39. 13 13
      jme3-core/src/main/java/com/jme3/light/LightProbe.java
  40. 12 1
      jme3-core/src/main/java/com/jme3/light/PointLight.java
  41. 16 5
      jme3-core/src/main/java/com/jme3/light/SpotLight.java
  42. 85 63
      jme3-core/src/main/java/com/jme3/material/Material.java
  43. 9 5
      jme3-core/src/main/java/com/jme3/material/Materials.java
  44. 109 104
      jme3-core/src/main/java/com/jme3/math/ColorRGBA.java
  45. 99 34
      jme3-core/src/main/java/com/jme3/math/FastMath.java
  46. 1 1
      jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java
  47. 3 1
      jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java
  48. 22 5
      jme3-core/src/main/java/com/jme3/scene/VertexBuffer.java
  49. 157 60
      jme3-core/src/main/java/com/jme3/scene/control/LightControl.java
  50. 113 27
      jme3-core/src/main/java/com/jme3/scene/debug/WireFrustum.java
  51. 21 5
      jme3-core/src/main/java/com/jme3/scene/debug/WireSphere.java
  52. 340 112
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java
  53. 136 50
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugger.java
  54. 26 10
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureInterJointsWire.java
  55. 215 57
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureNode.java
  56. 110 13
      jme3-core/src/main/java/com/jme3/scene/mesh/MorphTarget.java
  57. 14 7
      jme3-core/src/main/resources/Common/IBL/IBLKernels.frag
  58. 3 2
      jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.j3md
  59. 1 1
      jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.vert
  60. 38 0
      jme3-core/src/main/resources/Common/MatDefs/Misc/Dashed.j3md
  61. 23 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn
  62. 9 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag
  63. BIN
      jme3-core/src/main/resources/Common/Textures/lightbulb32.png
  64. 153 72
      jme3-core/src/plugins/java/com/jme3/audio/plugins/WAVLoader.java
  65. 7 9
      jme3-core/src/plugins/java/com/jme3/material/plugins/ShaderNodeDefinitionLoader.java
  66. 62 0
      jme3-core/src/test/java/com/jme3/audio/AudioFilterTest.java
  67. 48 0
      jme3-core/src/test/java/com/jme3/audio/AudioNodeTest.java
  68. 78 0
      jme3-core/src/test/java/com/jme3/effect/influencers/ParticleInfluencerTest.java
  69. 30 0
      jme3-core/src/test/java/com/jme3/math/ColorRGBATest.java
  70. 79 69
      jme3-effects/src/main/java/com/jme3/post/ssao/SSAOFilter.java
  71. 63 0
      jme3-effects/src/test/java/com/jme3/post/filters/SSAOFilterTest.java
  72. 60 40
      jme3-examples/src/main/java/jme3test/audio/TestAmbient.java
  73. 63 0
      jme3-examples/src/main/java/jme3test/audio/TestAudioDeviceDisconnect.java
  74. 141 0
      jme3-examples/src/main/java/jme3test/audio/TestAudioDirectional.java
  75. 48 21
      jme3-examples/src/main/java/jme3test/audio/TestDoppler.java
  76. 150 14
      jme3-examples/src/main/java/jme3test/audio/TestOgg.java
  77. 136 43
      jme3-examples/src/main/java/jme3test/audio/TestReverb.java
  78. 95 20
      jme3-examples/src/main/java/jme3test/audio/TestWav.java
  79. 79 23
      jme3-examples/src/main/java/jme3test/export/TestOgreConvert.java
  80. 84 0
      jme3-examples/src/main/java/jme3test/math/TestRandomPoints.java
  81. 92 0
      jme3-examples/src/main/java/jme3test/scene/instancing/TestInstanceNodeWithPbr.java
  82. 85 43
      jme3-examples/src/main/java/jme3test/stress/TestLodGeneration.java
  83. 171 158
      jme3-examples/src/main/java/jme3test/water/TestPostWater.java
  84. 2 2
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglCanvas.java
  85. 7 0
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java
  86. 3 2
      jme3-screenshot-tests/README.md
  87. 3 3
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  88. 143 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestIssue2076.java
  89. 188 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/animation/TestMotionPath.java
  90. 192 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRLighting.java
  91. 138 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSimple.java
  92. 125 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/material/TestSimpleBumps.java
  93. 151 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/model/shape/TestBillboard.java
  94. 150 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestCartoonEdge.java
  95. 172 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestFog.java
  96. 126 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/post/TestLightScattering.java
  97. 132 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/scene/instancing/TestInstanceNodeWithPbr.java
  98. 291 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrain.java
  99. 368 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/terrain/TestPBRTerrainAdvanced.java
  100. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.animation.TestIssue2076.testIssue2076_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

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

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

+ 1 - 1
gradle.properties

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

+ 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

+ 152 - 0
jme3-core/src/main/java/com/jme3/audio/BandPassFilter.java

@@ -0,0 +1,152 @@
+/*
+ * 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.audio;
+
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.util.NativeObject;
+
+import java.io.IOException;
+
+/**
+ * Represents an OpenAL EFX Band-Pass Filter.
+ */
+public class BandPassFilter extends Filter {
+
+    // Default values based on OpenAL EFX specification defaults
+    protected float volume = 1.0f;
+    protected float highFreqVolume = 1.0f;
+    protected float lowFreqVolume = 1.0f;
+
+    /**
+     * Constructs a band-pass filter with default settings.
+     * Required for jME deserialization
+     */
+    public BandPassFilter() {}
+
+    protected BandPassFilter(int id) {
+        super(id);
+    }
+
+    public BandPassFilter(float volume, float highFreqVolume, float lowFreqVolume) {
+        super();
+        setVolume(volume);
+        setHighFreqVolume(highFreqVolume);
+        setLowFreqVolume(lowFreqVolume);
+    }
+
+    public float getVolume() {
+        return volume;
+    }
+
+    /**
+     * Sets the overall gain of the Band-Pass filter.
+     *
+     * @param volume The gain value (0.0 to 1.0).
+     */
+    public void setVolume(float volume) {
+        if (volume < 0 || volume > 1)
+            throw new IllegalArgumentException("Volume must be between 0 and 1");
+
+        this.volume = volume;
+        this.updateNeeded = true;
+    }
+
+    public float getHighFreqVolume() {
+        return highFreqVolume;
+    }
+
+    /**
+     * Sets the gain at high frequencies for the Band-Pass filter.
+     *
+     * @param highFreqVolume The high-frequency gain value (0.0 to 1.0).
+     */
+    public void setHighFreqVolume(float highFreqVolume) {
+        if (highFreqVolume < 0 || highFreqVolume > 1)
+            throw new IllegalArgumentException("High freq volume must be between 0 and 1");
+
+        this.highFreqVolume = highFreqVolume;
+        this.updateNeeded = true;
+    }
+
+    public float getLowFreqVolume() {
+        return lowFreqVolume;
+    }
+
+    /**
+     * Sets the gain at low frequencies for the Band-Pass filter.
+     *
+     * @param lowFreqVolume The low-frequency gain value (0.0 to 1.0).
+     */
+    public void setLowFreqVolume(float lowFreqVolume) {
+        if (lowFreqVolume < 0 || lowFreqVolume > 1)
+            throw new IllegalArgumentException("Low freq volume must be between 0 and 1");
+
+        this.lowFreqVolume = lowFreqVolume;
+        this.updateNeeded = true;
+    }
+
+    @Override
+    public NativeObject createDestructableClone() {
+        return new BandPassFilter(this.id);
+    }
+
+    /**
+     * Retrieves a unique identifier for this filter. Used internally for native object management.
+     *
+     * @return a unique long identifier.
+     */
+    @Override
+    public long getUniqueId() {
+        return ((long) OBJTYPE_FILTER << 32) | (0xffffffffL & (long) id);
+    }
+
+    @Override
+    public void write(JmeExporter ex) throws IOException {
+        super.write(ex);
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(this.volume, "volume", 1f);
+        oc.write(this.lowFreqVolume, "lf_volume", 1f);
+        oc.write(this.highFreqVolume, "hf_volume", 1f);
+    }
+
+    @Override
+    public void read(JmeImporter im) throws IOException {
+        super.read(im);
+        InputCapsule ic = im.getCapsule(this);
+        this.volume = ic.readFloat("volume", 1f);
+        this.lowFreqVolume = ic.readFloat("lf_volume", 1f);
+        this.highFreqVolume = ic.readFloat("hf_volume", 1f);
+    }
+}

+ 153 - 53
jme3-core/src/main/java/com/jme3/audio/Environment.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -34,49 +34,80 @@ package com.jme3.audio;
 import com.jme3.math.FastMath;
 
 /**
- * Audio environment, for reverb effects.
+ * Represents an audio environment, primarily used to define reverb effects.
+ * This class provides parameters that correspond to the properties controllable
+ * through the OpenAL EFX (Environmental Effects Extension) library.
+ * By adjusting these parameters, developers can simulate various acoustic spaces
+ * like rooms, caves, and concert halls, adding depth and realism to the audio experience.
+ *
  * @author Kirill
  */
 public class Environment {
 
-    private float airAbsorbGainHf   = 0.99426f;
+    /** High-frequency air absorption gain (0.0f to 1.0f). */
+    private float airAbsorbGainHf = 0.99426f;
+    /** Factor controlling room effect rolloff with distance. */
     private float roomRolloffFactor = 0;
-
-    private float decayTime         = 1.49f;
-    private float decayHFRatio      = 0.54f;
-
-    private float density           = 1.0f;
-    private float diffusion         = 0.3f;
-
-    private float gain              = 0.316f;
-    private float gainHf            = 0.022f;
-
-    private float lateReverbDelay   = 0.088f;
-    private float lateReverbGain    = 0.768f;
-
-    private float reflectDelay      = 0.162f;
-    private float reflectGain       = 0.052f;
-
-    private boolean decayHfLimit    = true;
-
-    public static final Environment Garage, Dungeon, Cavern, AcousticLab, Closet;
-
-    static {
-        Garage = new Environment(1, 1, 1, 1, .9f, .5f, .751f, .0039f, .661f, .0137f);
-        Dungeon = new Environment(.75f, 1, 1, .75f, 1.6f, 1, 0.95f, 0.0026f, 0.93f, 0.0103f);
-        Cavern = new Environment(.5f, 1, 1, .5f, 2.25f, 1, .908f, .0103f, .93f, .041f);
-        AcousticLab = new Environment(.5f, 1, 1, 1, .28f, 1, .87f, .002f, .81f, .008f);
-        Closet = new Environment(1, 1, 1, 1, .15f, 1, .6f, .0025f, .5f, .0006f);
-    }
-
+    /** Overall decay time of the reverberation (in seconds). */
+    private float decayTime = 1.49f;
+    /** Ratio of high-frequency decay time to overall decay time (0.0f to 1.0f). */
+    private float decayHFRatio = 0.54f;
+    /** Density of the medium affecting reverb smoothness (0.0f to 1.0f). */
+    private float density = 1.0f;
+    /** Diffusion of reflections affecting echo distinctness (0.0f to 1.0f). */
+    private float diffusion = 0.3f;
+    /** Overall gain of the environment effect (linear scale). */
+    private float gain = 0.316f;
+    /** High-frequency gain of the environment effect (linear scale). */
+    private float gainHf = 0.022f;
+    /** Delay time for late reverberation relative to early reflections (in seconds). */
+    private float lateReverbDelay = 0.088f;
+    /** Gain of the late reverberation (linear scale). */
+    private float lateReverbGain = 0.768f;
+    /** Delay time for the initial reflections (in seconds). */
+    private float reflectDelay = 0.162f;
+    /** Gain of the initial reflections (linear scale). */
+    private float reflectGain = 0.052f;
+    /** Flag limiting high-frequency decay by the overall decay time. */
+    private boolean decayHfLimit = true;
+
+    public static final Environment Garage = new Environment(
+            1, 1, 1, 1, .9f, .5f, .751f, .0039f, .661f, .0137f);
+    public static final Environment Dungeon = new Environment(
+            .75f, 1, 1, .75f, 1.6f, 1, 0.95f, 0.0026f, 0.93f, 0.0103f);
+    public static final Environment Cavern = new Environment(
+            .5f, 1, 1, .5f, 2.25f, 1, .908f, .0103f, .93f, .041f);
+    public static final Environment AcousticLab = new Environment(
+            .5f, 1, 1, 1, .28f, 1, .87f, .002f, .81f, .008f);
+    public static final Environment Closet = new Environment(
+            1, 1, 1, 1, .15f, 1, .6f, .0025f, .5f, .0006f);
+
+    /**
+     * Utility method to convert an EAX decibel value to an amplitude factor.
+     * EAX often expresses gain and attenuation in decibels scaled by 1000.
+     * This method performs the reverse of that conversion to obtain a linear
+     * amplitude value suitable for OpenAL.
+     *
+     * @param eaxDb The EAX decibel value (scaled by 1000).
+     * @return The corresponding amplitude factor.
+     */
     private static float eaxDbToAmp(float eaxDb) {
         float dB = eaxDb / 2000f;
         return FastMath.pow(10f, dB);
     }
 
+    /**
+     * Constructs a new, default {@code Environment}. The default values are
+     * typically chosen to represent a neutral or common acoustic space.
+     */
     public Environment() {
     }
 
+    /**
+     * Creates a new {@code Environment} as a copy of the provided {@code Environment}.
+     *
+     * @param source The {@code Environment} to copy the settings from.
+     */
     public Environment(Environment source) {
         this.airAbsorbGainHf = source.airAbsorbGainHf;
         this.roomRolloffFactor = source.roomRolloffFactor;
@@ -93,9 +124,24 @@ public class Environment {
         this.decayHfLimit = source.decayHfLimit;
     }
 
+    /**
+     * Creates a new {@code Environment} with the specified parameters. These parameters
+     * directly influence the properties of the reverb effect as managed by OpenAL EFX.
+     *
+     * @param density      The density of the medium.
+     * @param diffusion    The diffusion of the reflections.
+     * @param gain         Overall gain applied to the environment effect.
+     * @param gainHf       High-frequency gain applied to the environment effect.
+     * @param decayTime    The overall decay time of the reflected sound.
+     * @param decayHf      Ratio of high-frequency decay time to the overall decay time.
+     * @param reflectGain  Gain applied to the initial reflections.
+     * @param reflectDelay Delay time for the initial reflections.
+     * @param lateGain     Gain applied to the late reverberation.
+     * @param lateDelay    Delay time for the late reverberation.
+     */
     public Environment(float density, float diffusion, float gain, float gainHf,
-                       float decayTime, float decayHf, float reflectGain,
-                       float reflectDelay, float lateGain, float lateDelay) {
+                       float decayTime, float decayHf, float reflectGain, float reflectDelay,
+                       float lateGain, float lateDelay) {
         this.decayTime = decayTime;
         this.decayHFRatio = decayHf;
         this.density = density;
@@ -108,6 +154,16 @@ public class Environment {
         this.reflectGain = reflectGain;
     }
 
+    /**
+     * Creates a new {@code Environment} by interpreting an array of 28 float values
+     * as an EAX preset. This constructor attempts to map the EAX preset values to
+     * the corresponding OpenAL EFX parameters. Note that not all EAX parameters
+     * have a direct equivalent in standard OpenAL EFX, so some values might be
+     * approximated or ignored.
+     *
+     * @param e An array of 28 float values representing an EAX preset.
+     * @throws IllegalArgumentException If the provided array does not have a length of 28.
+     */
     public Environment(float[] e) {
         if (e.length != 28)
             throw new IllegalArgumentException("Not an EAX preset");
@@ -254,27 +310,71 @@ public class Environment {
     }
 
     @Override
-    public boolean equals(Object env2) {
-        if (env2 == null)
+    public boolean equals(Object obj) {
+
+        if (!(obj instanceof Environment))
             return false;
-        if (env2 == this)
+
+        if (obj == this)
             return true;
-        if (!(env2 instanceof Environment))
-            return false;
 
-        Environment e2 = (Environment) env2;
-        return (e2.airAbsorbGainHf == airAbsorbGainHf
-                && e2.decayHFRatio == decayHFRatio
-                && e2.decayHfLimit == decayHfLimit
-                && e2.decayTime == decayTime
-                && e2.density == density
-                && e2.diffusion == diffusion
-                && e2.gain == gain
-                && e2.gainHf == gainHf
-                && e2.lateReverbDelay == lateReverbDelay
-                && e2.lateReverbGain == lateReverbGain
-                && e2.reflectDelay == reflectDelay
-                && e2.reflectGain == reflectGain
-                && e2.roomRolloffFactor == roomRolloffFactor);
-    } 
+        Environment other = (Environment) obj;
+        float epsilon = 1e-6f;
+
+        float[] thisFloats = {
+                this.airAbsorbGainHf,
+                this.decayHFRatio,
+                this.decayTime,
+                this.density,
+                this.diffusion,
+                this.gain,
+                this.gainHf,
+                this.lateReverbDelay,
+                this.lateReverbGain,
+                this.reflectDelay,
+                this.reflectGain,
+                this.roomRolloffFactor
+        };
+
+        float[] otherFloats = {
+                other.airAbsorbGainHf,
+                other.decayHFRatio,
+                other.decayTime,
+                other.density,
+                other.diffusion,
+                other.gain,
+                other.gainHf,
+                other.lateReverbDelay,
+                other.lateReverbGain,
+                other.reflectDelay,
+                other.reflectGain,
+                other.roomRolloffFactor
+        };
+
+        for (int i = 0; i < thisFloats.length; i++) {
+            if (Math.abs(thisFloats[i] - otherFloats[i]) >= epsilon) {
+                return false;
+            }
+        }
+
+        return this.decayHfLimit == other.decayHfLimit;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = (airAbsorbGainHf != +0.0f ? Float.floatToIntBits(airAbsorbGainHf) : 0);
+        result = 31 * result + (roomRolloffFactor != +0.0f ? Float.floatToIntBits(roomRolloffFactor) : 0);
+        result = 31 * result + (decayTime != +0.0f ? Float.floatToIntBits(decayTime) : 0);
+        result = 31 * result + (decayHFRatio != +0.0f ? Float.floatToIntBits(decayHFRatio) : 0);
+        result = 31 * result + (density != +0.0f ? Float.floatToIntBits(density) : 0);
+        result = 31 * result + (diffusion != +0.0f ? Float.floatToIntBits(diffusion) : 0);
+        result = 31 * result + (gain != +0.0f ? Float.floatToIntBits(gain) : 0);
+        result = 31 * result + (gainHf != +0.0f ? Float.floatToIntBits(gainHf) : 0);
+        result = 31 * result + (lateReverbDelay != +0.0f ? Float.floatToIntBits(lateReverbDelay) : 0);
+        result = 31 * result + (lateReverbGain != +0.0f ? Float.floatToIntBits(lateReverbGain) : 0);
+        result = 31 * result + (reflectDelay != +0.0f ? Float.floatToIntBits(reflectDelay) : 0);
+        result = 31 * result + (reflectGain != +0.0f ? Float.floatToIntBits(reflectGain) : 0);
+        result = 31 * result + (decayHfLimit ? 1 : 0);
+        return result;
+    }
 }

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

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

+ 131 - 0
jme3-core/src/main/java/com/jme3/audio/HighPassFilter.java

@@ -0,0 +1,131 @@
+/*
+ * 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.audio;
+
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.util.NativeObject;
+
+import java.io.IOException;
+
+/**
+ * Represents an OpenAL EFX High-Pass Filter.
+ */
+public class HighPassFilter extends Filter {
+
+    // Default values based on OpenAL EFX specification defaults
+    protected float volume = 1.0f;
+    protected float lowFreqVolume = 1.0f;
+
+    /**
+     * Constructs a high-pass filter with default settings.
+     * Required for jME deserialization
+     */
+    public HighPassFilter(){}
+
+    protected HighPassFilter(int id) {
+        super(id);
+    }
+
+    public HighPassFilter(float volume, float lowFreqVolume) {
+        super();
+        setVolume(volume);
+        setLowFreqVolume(lowFreqVolume);
+    }
+
+    public float getVolume() {
+        return volume;
+    }
+
+    /**
+     * Sets the gain of the High-Pass filter.
+     *
+     * @param volume The gain value (0.0 to 1.0).
+     */
+    public void setVolume(float volume) {
+        if (volume < 0 || volume > 1)
+            throw new IllegalArgumentException("Volume must be between 0 and 1");
+
+        this.volume = volume;
+        this.updateNeeded = true;
+    }
+
+    public float getLowFreqVolume() {
+        return lowFreqVolume;
+    }
+
+    /**
+     * Sets the gain at low frequencies for the High-Pass filter.
+     *
+     * @param lowFreqVolume The low-frequency gain value (0.0 to 1.0).
+     */
+    public void setLowFreqVolume(float lowFreqVolume) {
+        if (lowFreqVolume < 0 || lowFreqVolume > 1)
+            throw new IllegalArgumentException("Low freq volume must be between 0 and 1");
+
+        this.lowFreqVolume = lowFreqVolume;
+        this.updateNeeded = true;
+    }
+
+    @Override
+    public NativeObject createDestructableClone() {
+        return new HighPassFilter(this.id);
+    }
+
+    /**
+     * Retrieves a unique identifier for this filter. Used internally for native object management.
+     *
+     * @return a unique long identifier.
+     */
+    @Override
+    public long getUniqueId() {
+        return ((long) OBJTYPE_FILTER << 32) | (0xffffffffL & (long) id);
+    }
+
+    @Override
+    public void write(JmeExporter ex) throws IOException {
+        super.write(ex);
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(this.volume, "volume", 1f);
+        oc.write(this.lowFreqVolume, "lf_volume", 1f);
+    }
+
+    @Override
+    public void read(JmeImporter im) throws IOException {
+        super.read(im);
+        InputCapsule ic = im.getCapsule(this);
+        this.volume = ic.readFloat("volume", 1f);
+        this.lowFreqVolume = ic.readFloat("lf_volume", 1f);
+    }
+}

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

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

+ 72 - 6
jme3-core/src/main/java/com/jme3/audio/LowPassFilter.java

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

File diff suppressed because it is too large
+ 436 - 289
jme3-core/src/main/java/com/jme3/audio/openal/ALAudioRenderer.java


+ 26 - 26
jme3-core/src/main/java/com/jme3/audio/openal/EFX.java

@@ -27,19 +27,19 @@ public interface EFX {
     /* Effect properties. */
 
     /* Reverb effect parameters */
-    public static final int AL_REVERB_DENSITY = 0x0001;
-    public static final int AL_REVERB_DIFFUSION = 0x0002;
-    public static final int AL_REVERB_GAIN = 0x0003;
-    public static final int AL_REVERB_GAINHF = 0x0004;
-    public static final int AL_REVERB_DECAY_TIME = 0x0005;
-    public static final int AL_REVERB_DECAY_HFRATIO = 0x0006;
-    public static final int AL_REVERB_REFLECTIONS_GAIN = 0x0007;
-    public static final int AL_REVERB_REFLECTIONS_DELAY = 0x0008;
-    public static final int AL_REVERB_LATE_REVERB_GAIN = 0x0009;
-    public static final int AL_REVERB_LATE_REVERB_DELAY = 0x000A;
+    public static final int AL_REVERB_DENSITY               = 0x0001;
+    public static final int AL_REVERB_DIFFUSION             = 0x0002;
+    public static final int AL_REVERB_GAIN                  = 0x0003;
+    public static final int AL_REVERB_GAINHF                = 0x0004;
+    public static final int AL_REVERB_DECAY_TIME            = 0x0005;
+    public static final int AL_REVERB_DECAY_HFRATIO         = 0x0006;
+    public static final int AL_REVERB_REFLECTIONS_GAIN      = 0x0007;
+    public static final int AL_REVERB_REFLECTIONS_DELAY     = 0x0008;
+    public static final int AL_REVERB_LATE_REVERB_GAIN      = 0x0009;
+    public static final int AL_REVERB_LATE_REVERB_DELAY     = 0x000A;
     public static final int AL_REVERB_AIR_ABSORPTION_GAINHF = 0x000B;
-    public static final int AL_REVERB_ROOM_ROLLOFF_FACTOR = 0x000C;
-    public static final int AL_REVERB_DECAY_HFLIMIT = 0x000D;
+    public static final int AL_REVERB_ROOM_ROLLOFF_FACTOR   = 0x000C;
+    public static final int AL_REVERB_DECAY_HFLIMIT         = 0x000D;
 
     /* EAX Reverb effect parameters */
     //#define AL_EAXREVERB_DENSITY                     0x0001
@@ -171,28 +171,28 @@ public interface EFX {
     ///* Filter properties. */
 
     /* Lowpass filter parameters */
-    public static final int AL_LOWPASS_GAIN = 0x0001;
-    public static final int AL_LOWPASS_GAINHF = 0x0002;
+    public static final int AL_LOWPASS_GAIN     = 0x0001;
+    public static final int AL_LOWPASS_GAINHF   = 0x0002;
 
-    ///* Highpass filter parameters */
-    //#define AL_HIGHPASS_GAIN                         0x0001
-    //#define AL_HIGHPASS_GAINLF                       0x0002
+    // * Highpass filter parameters */
+    public static final int AL_HIGHPASS_GAIN    = 0x0001;
+    public static final int AL_HIGHPASS_GAINLF  = 0x0002;
 
-    ///* Bandpass filter parameters */
-    //#define AL_BANDPASS_GAIN                         0x0001
-    //#define AL_BANDPASS_GAINLF                       0x0002
-    //#define AL_BANDPASS_GAINHF                       0x0003
+    // * Bandpass filter parameters */
+    public static final int AL_BANDPASS_GAIN    = 0x0001;
+    public static final int AL_BANDPASS_GAINLF  = 0x0002;
+    public static final int AL_BANDPASS_GAINHF  = 0x0003;
 
     /* Filter type */
     //#define AL_FILTER_FIRST_PARAMETER                0x0000
     //#define AL_FILTER_LAST_PARAMETER                 0x8000
-    public static final int AL_FILTER_TYPE = 0x8001;
+    public static final int AL_FILTER_TYPE      = 0x8001;
 
     /* Filter types, used with the AL_FILTER_TYPE property */
-    public static final int AL_FILTER_NULL = 0x0000;
-    public static final int AL_FILTER_LOWPASS = 0x0001;
-    public static final int AL_FILTER_HIGHPASS = 0x0002;
-    //#define AL_FILTER_BANDPASS                       0x0003
+    public static final int AL_FILTER_NULL      = 0x0000;
+    public static final int AL_FILTER_LOWPASS   = 0x0001;
+    public static final int AL_FILTER_HIGHPASS  = 0x0002;
+    public static final int AL_FILTER_BANDPASS  = 0x0003;
 
     ///* Filter ranges and defaults. */
     //

+ 51 - 18
jme3-core/src/main/java/com/jme3/cinematic/events/CameraEvent.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,27 +40,37 @@ import com.jme3.export.OutputCapsule;
 import java.io.IOException;
 
 /**
+ * A `CameraEvent` is a cinematic event that instantly sets the active camera
+ * within a `Cinematic` sequence.
  *
  * @author Rickard (neph1 @ github)
  */
 public class CameraEvent extends AbstractCinematicEvent {
 
+    /**
+     * The name of the camera to activate.
+     */
     private String cameraName;
+    /**
+     * The `Cinematic` instance to which this event belongs and on which the
+     * camera will be set.
+     */
     private Cinematic cinematic;
 
-    public String getCameraName() {
-        return cameraName;
-    }
-
-    public void setCameraName(String cameraName) {
-        this.cameraName = cameraName;
-    }
-
+    /**
+     * For serialization only. Do not use.
+     */
     public CameraEvent() {
     }
 
-    public CameraEvent(Cinematic parentEvent, String cameraName) {
-        this.cinematic = parentEvent;
+    /**
+     * Constructs a new `CameraEvent` with the specified cinematic and camera name.
+     *
+     * @param cinematic  The `Cinematic` instance this event belongs to (cannot be null).
+     * @param cameraName The name of the camera to be activated by this event (cannot be null or empty).
+     */
+    public CameraEvent(Cinematic cinematic, String cameraName) {
+        this.cinematic = cinematic;
         this.cameraName = cameraName;
     }
 
@@ -102,33 +112,56 @@ public class CameraEvent extends AbstractCinematicEvent {
         play();
     }
 
+    /**
+     * Returns the `Cinematic` instance associated with this event.
+     * @return The `Cinematic` instance.
+     */
     public Cinematic getCinematic() {
         return cinematic;
     }
 
+    /**
+     * Sets the `Cinematic` instance for this event.
+     * @param cinematic The `Cinematic` instance to set (cannot be null).
+     */
     public void setCinematic(Cinematic cinematic) {
         this.cinematic = cinematic;
     }
 
     /**
-     * used internally for serialization
+     * Returns the name of the camera that this event will activate.
+     * @return The camera name.
+     */
+    public String getCameraName() {
+        return cameraName;
+    }
+
+    /**
+     * Sets the name of the camera that this event will activate.
+     * @param cameraName The new camera name (cannot be null or empty).
+     */
+    public void setCameraName(String cameraName) {
+        this.cameraName = cameraName;
+    }
+
+    /**
+     * Used internally for serialization.
      *
-     * @param ex the exporter (not null)
-     * @throws IOException from the exporter
+     * @param ex The exporter (not null).
+     * @throws IOException If an I/O error occurs during serialization.
      */
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
         OutputCapsule oc = ex.getCapsule(this);
         oc.write(cameraName, "cameraName", null);
-
     }
 
     /**
-     * used internally for serialization
+     * Used internally for deserialization.
      *
-     * @param im the importer (not null)
-     * @throws IOException from the importer
+     * @param im The importer (not null).
+     * @throws IOException If an I/O error occurs during deserialization.
      */
     @Override
     public void read(JmeImporter im) throws IOException {

+ 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();

+ 6 - 8
jme3-core/src/main/java/com/jme3/effect/influencers/DefaultParticleInfluencer.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,6 +40,7 @@ import com.jme3.export.OutputCapsule;
 import com.jme3.math.FastMath;
 import com.jme3.math.Vector3f;
 import com.jme3.util.clone.Cloner;
+
 import java.io.IOException;
 
 /**
@@ -101,13 +102,10 @@ public class DefaultParticleInfluencer implements ParticleInfluencer {
 
     @Override
     public DefaultParticleInfluencer clone() {
-        try {
-            DefaultParticleInfluencer clone = (DefaultParticleInfluencer) super.clone();
-            clone.initialVelocity = initialVelocity.clone();
-            return clone;
-        } catch (CloneNotSupportedException e) {
-            throw new AssertionError();
-        }
+        // Set up the cloner for the type of cloning we want to do.
+        Cloner cloner = new Cloner();
+        DefaultParticleInfluencer clone = cloner.clone(this);
+        return clone;
     }
 
     /**

+ 1 - 12
jme3-core/src/main/java/com/jme3/effect/influencers/NewtonianParticleInfluencer.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
@@ -142,17 +142,6 @@ public class NewtonianParticleInfluencer extends DefaultParticleInfluencer {
         particle.velocity.addLocal(temp);
     }
 
-    @Override
-    public NewtonianParticleInfluencer clone() {
-        NewtonianParticleInfluencer result = new NewtonianParticleInfluencer();
-        result.normalVelocity = normalVelocity;
-        result.initialVelocity = initialVelocity;
-        result.velocityVariation = velocityVariation;
-        result.surfaceTangentFactor = surfaceTangentFactor;
-        result.surfaceTangentRotation = surfaceTangentRotation;
-        return result;
-    }
-
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);

+ 3 - 3
jme3-core/src/main/java/com/jme3/effect/influencers/ParticleInfluencer.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
@@ -42,7 +42,7 @@ import com.jme3.util.clone.JmeCloneable;
  * An interface that defines the methods to affect initial velocity of the particles.
  * @author Marcin Roguski (Kaelthas)
  */
-public interface ParticleInfluencer extends Savable, Cloneable, JmeCloneable {
+public interface ParticleInfluencer extends Savable, JmeCloneable {
 
     /**
      * This method influences the particle.
@@ -57,7 +57,7 @@ public interface ParticleInfluencer extends Savable, Cloneable, JmeCloneable {
      * This method clones the influencer instance.
      * @return cloned instance
      */
-    public ParticleInfluencer clone();
+    ParticleInfluencer clone();
 
     /**
      * @param initialVelocity

+ 56 - 6
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterBoxShape.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
@@ -40,13 +40,35 @@ import com.jme3.math.Vector3f;
 import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 
+/**
+ * An {@link EmitterShape} that emits particles randomly within the bounds of an axis-aligned box.
+ * The box is defined by a minimum corner and a length vector.
+ */
 public class EmitterBoxShape implements EmitterShape {
 
-    private Vector3f min, len;
+    /**
+     * The minimum corner of the box.
+     */
+    private Vector3f min;
+    /**
+     * The length of the box along each axis.  The x, y, and z components of this
+     * vector represent the width, height, and depth of the box, respectively.
+     */
+    private Vector3f len;
 
+    /**
+     * For serialization only. Do not use.
+     */
     public EmitterBoxShape() {
     }
 
+    /**
+     * Constructs an {@code EmitterBoxShape} defined by a minimum and maximum corner.
+     *
+     * @param min The minimum corner of the box.
+     * @param max The maximum corner of the box.
+     * @throws IllegalArgumentException If either {@code min} or {@code max} is null.
+     */
     public EmitterBoxShape(Vector3f min, Vector3f max) {
         if (min == null || max == null) {
             throw new IllegalArgumentException("min or max cannot be null");
@@ -57,6 +79,11 @@ public class EmitterBoxShape implements EmitterShape {
         this.len.set(max).subtractLocal(min);
     }
 
+    /**
+     * Generates a random point within the bounds of the box.
+     *
+     * @param store The {@link Vector3f} to store the generated point in.
+     */
     @Override
     public void getRandomPoint(Vector3f store) {
         store.x = min.x + len.x * FastMath.nextRandomFloat();
@@ -65,10 +92,11 @@ public class EmitterBoxShape implements EmitterShape {
     }
 
     /**
-     * This method fills the point with data.
-     * It does not fill the normal.
-     * @param store the variable to store the point data
-     * @param normal not used in this class
+     * For a box shape, the normal is not well-defined for points within the volume.
+     * This implementation simply calls {@link #getRandomPoint(Vector3f)} and does not modify the provided normal.
+     *
+     * @param store  The {@link Vector3f} to store the generated point in.
+     * @param normal The {@link Vector3f} to store the generated normal in (unused).
      */
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
@@ -108,18 +136,40 @@ public class EmitterBoxShape implements EmitterShape {
         this.len = cloner.clone(len);
     }
 
+    /**
+     * Returns the minimum corner of the emitting box.
+     *
+     * @return The minimum corner.
+     */
     public Vector3f getMin() {
         return min;
     }
 
+    /**
+     * Sets the minimum corner of the emitting box.
+     *
+     * @param min The new minimum corner.
+     */
     public void setMin(Vector3f min) {
         this.min = min;
     }
 
+    /**
+     * Returns the length vector of the emitting box. This vector represents the
+     * extent of the box along each axis (length = max - min).
+     *
+     * @return The length vector.
+     */
     public Vector3f getLen() {
         return len;
     }
 
+    /**
+     * Sets the length vector of the emitting box. This vector should represent
+     * the extent of the box along each axis (length = max - min).
+     *
+     * @param len The new length vector.
+     */
     public void setLen(Vector3f len) {
         this.len = len;
     }

+ 39 - 6
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterPointShape.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
@@ -31,6 +31,7 @@
  */
 package com.jme3.effect.shapes;
 
+import com.jme3.export.InputCapsule;
 import com.jme3.export.JmeExporter;
 import com.jme3.export.JmeImporter;
 import com.jme3.export.OutputCapsule;
@@ -38,13 +39,27 @@ import com.jme3.math.Vector3f;
 import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 
+/**
+ * An {@link EmitterShape} that emits particles from a single point in space.
+ */
 public class EmitterPointShape implements EmitterShape {
 
+    /**
+     * The point in space from which particles are emitted.
+     */
     private Vector3f point;
 
+    /**
+     * For serialization only. Do not use.
+     */
     public EmitterPointShape() {
     }
 
+    /**
+     * Constructs an {@code EmitterPointShape} with the given point.
+     *
+     * @param point The point from which particles are emitted.
+     */
     public EmitterPointShape(Vector3f point) {
         this.point = point;
     }
@@ -80,26 +95,43 @@ public class EmitterPointShape implements EmitterShape {
         this.point = cloner.clone(point);
     }
 
+    /**
+     * For a point shape, the generated point is
+     * always the shape's defined point.
+     *
+     * @param store The {@link Vector3f} to store the generated point in.
+     */
     @Override
     public void getRandomPoint(Vector3f store) {
         store.set(point);
     }
 
     /**
-     * This method fills the point with data.
-     * It does not fill the normal.
-     * @param store the variable to store the point data
-     * @param normal not used in this class
+     * For a point shape, the generated point is always the shape's defined point.
+     * The normal is not defined for a point shape, so this method does not modify the normal parameter.
+     *
+     * @param store  The {@link Vector3f} to store the generated point in.
+     * @param normal The {@link Vector3f} to store the generated normal in (unused).
      */
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
         store.set(point);
     }
 
+    /**
+     * Returns the point from which particles are emitted.
+     *
+     * @return The point.
+     */
     public Vector3f getPoint() {
         return point;
     }
 
+    /**
+     * Sets the point from which particles are emitted.
+     *
+     * @param point The new point.
+     */
     public void setPoint(Vector3f point) {
         this.point = point;
     }
@@ -112,6 +144,7 @@ public class EmitterPointShape implements EmitterShape {
 
     @Override
     public void read(JmeImporter im) throws IOException {
-        this.point = (Vector3f) im.getCapsule(this).readSavable("point", null);
+        InputCapsule ic = im.getCapsule(this);
+        this.point = (Vector3f) ic.readSavable("point", null);
     }
 }

+ 5 - 5
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterShape.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
@@ -39,14 +39,14 @@ import com.jme3.util.clone.JmeCloneable;
  * This interface declares methods used by all shapes that represent particle emitters.
  * @author Kirill
  */
-public interface EmitterShape extends Savable, Cloneable, JmeCloneable {
+public interface EmitterShape extends Savable, JmeCloneable {
 
     /**
      * This method fills in the initial position of the particle.
      * @param store
      *        store variable for initial position
      */
-    public void getRandomPoint(Vector3f store);
+    void getRandomPoint(Vector3f store);
 
     /**
      * This method fills in the initial position of the particle and its normal vector.
@@ -55,11 +55,11 @@ public interface EmitterShape extends Savable, Cloneable, JmeCloneable {
      * @param normal
      *        store variable for initial normal
      */
-    public void getRandomPointAndNormal(Vector3f store, Vector3f normal);
+    void getRandomPointAndNormal(Vector3f store, Vector3f normal);
 
     /**
      * This method creates a deep clone of the current instance of the emitter shape.
      * @return deep clone of the current instance of the emitter shape
      */
-    public EmitterShape deepClone();
+    EmitterShape deepClone();
 }

+ 54 - 2
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterSphereShape.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
@@ -40,19 +40,38 @@ import com.jme3.math.Vector3f;
 import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 
+/**
+ * An {@link EmitterShape} that emits particles randomly from within the volume of a sphere.
+ * The sphere is defined by a center point and a radius.
+ */
 public class EmitterSphereShape implements EmitterShape {
 
+    /**
+     * The center point of the sphere.
+     */
     private Vector3f center;
+    /**
+     * The radius of the sphere.
+     */
     private float radius;
 
+    /**
+     * For serialization only. Do not use.
+     */
     public EmitterSphereShape() {
     }
 
+    /**
+     * Constructs an {@code EmitterSphereShape} with the given center and radius.
+     *
+     * @param center The center point of the sphere.
+     * @param radius The radius of the sphere.
+     * @throws IllegalArgumentException If {@code center} is null, or if {@code radius} is not greater than 0.
+     */
     public EmitterSphereShape(Vector3f center, float radius) {
         if (center == null) {
             throw new IllegalArgumentException("center cannot be null");
         }
-
         if (radius <= 0) {
             throw new IllegalArgumentException("Radius must be greater than 0");
         }
@@ -92,6 +111,11 @@ public class EmitterSphereShape implements EmitterShape {
         this.center = cloner.clone(center);
     }
 
+    /**
+     * Generates a random point within the volume of the sphere.
+     *
+     * @param store The {@link Vector3f} to store the generated point in.
+     */
     @Override
     public void getRandomPoint(Vector3f store) {
         do {
@@ -103,23 +127,51 @@ public class EmitterSphereShape implements EmitterShape {
         store.addLocal(center);
     }
 
+    /**
+     * For a sphere shape, the normal is not well-defined for points within the volume.
+     * This implementation simply calls {@link #getRandomPoint(Vector3f)} and does not modify the provided normal.
+     *
+     * @param store  The {@link Vector3f} to store the generated point in.
+     * @param normal The {@link Vector3f} to store the generated normal in (unused).
+     */
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
         this.getRandomPoint(store);
+        normal.set(store).subtractLocal(center).normalizeLocal();
     }
 
+    /**
+     * Returns the center point of the sphere.
+     *
+     * @return The center point.
+     */
     public Vector3f getCenter() {
         return center;
     }
 
+    /**
+     * Sets the center point of the sphere.
+     *
+     * @param center The new center point.
+     */
     public void setCenter(Vector3f center) {
         this.center = center;
     }
 
+    /**
+     * Returns the radius of the sphere.
+     *
+     * @return The radius.
+     */
     public float getRadius() {
         return radius;
     }
 
+    /**
+     * Sets the radius of the sphere.
+     *
+     * @param radius The new radius.
+     */
     public void setRadius(float radius) {
         this.radius = radius;
     }

+ 3 - 2
jme3-core/src/main/java/com/jme3/environment/EnvironmentProbeControl.java

@@ -287,8 +287,9 @@ public class EnvironmentProbeControl extends LightProbe implements Control {
     }
 
     void rebakeNow(RenderManager renderManager) {
-        IBLHybridEnvBakerLight baker = new IBLGLEnvBakerLight(renderManager, assetManager, Format.RGB16F, Format.Depth,
-                envMapSize, envMapSize);
+        IBLHybridEnvBakerLight baker = new IBLGLEnvBakerLight(renderManager, assetManager, Format.RGB16F,
+                Format.Depth,
+                        envMapSize, envMapSize);
                     
         baker.setTexturePulling(isRequiredSavableResults());
         baker.bakeEnvironment(spatial, getPosition(), frustumNear, frustumFar, filter);

+ 3 - 1
jme3-core/src/main/java/com/jme3/environment/FastLightProbeFactory.java

@@ -33,6 +33,7 @@ package com.jme3.environment;
 
 import com.jme3.asset.AssetManager;
 import com.jme3.environment.baker.IBLGLEnvBakerLight;
+import com.jme3.environment.baker.IBLHybridEnvBakerLight;
 import com.jme3.environment.util.EnvMapUtils;
 import com.jme3.light.LightProbe;
 import com.jme3.math.Vector3f;
@@ -74,7 +75,8 @@ public class FastLightProbeFactory {
      * @return The baked LightProbe
      */
     public static LightProbe makeProbe(RenderManager rm, AssetManager am, int size, Vector3f pos, float frustumNear, float frustumFar, Spatial scene) {
-        IBLGLEnvBakerLight baker = new IBLGLEnvBakerLight(rm, am, Format.RGB16F, Format.Depth, size, size);
+        IBLHybridEnvBakerLight baker = new IBLGLEnvBakerLight(rm, am, Format.RGB16F, Format.Depth, size,
+                        size);
 
         baker.setTexturePulling(true);
         baker.bakeEnvironment(scene, pos, frustumNear, frustumFar, null);

+ 4 - 2
jme3-core/src/main/java/com/jme3/environment/baker/IBLGLEnvBakerLight.java

@@ -34,6 +34,7 @@ package com.jme3.environment.baker;
 import java.nio.ByteBuffer;
 import java.util.logging.Logger;
 import com.jme3.asset.AssetManager;
+import com.jme3.environment.util.EnvMapUtils;
 import com.jme3.material.Material;
 import com.jme3.math.ColorRGBA;
 import com.jme3.math.FastMath;
@@ -130,11 +131,12 @@ public class IBLGLEnvBakerLight extends IBLHybridEnvBakerLight {
                 int s = renderOnT;
                 renderOnT = renderOnT == 0 ? 1 : 0;
                 mat.setTexture("ShCoef", shCoefTx[s]);
-                mat.setInt("FaceId", faceId);
             } else {
                 renderOnT = 0;
             }
 
+            mat.setInt("FaceId", faceId);
+
             screen.updateLogicalState(0);
             screen.updateGeometricState();
 
@@ -169,7 +171,7 @@ public class IBLGLEnvBakerLight extends IBLHybridEnvBakerLight {
             if (remapMaxValue > 0) shCoef[i].divideLocal(remapMaxValue);
             shCoef[i].multLocal(4.0f * FastMath.PI / weightAccum);
         }
-
+        EnvMapUtils.prepareShCoefs(shCoef);
         img.dispose();
 
     }

+ 1 - 0
jme3-core/src/main/java/com/jme3/environment/baker/IBLHybridEnvBakerLight.java

@@ -200,6 +200,7 @@ public class IBLHybridEnvBakerLight extends GenericEnvBaker implements IBLEnvBak
     @Override
     public void bakeSphericalHarmonicsCoefficients() {
         shCoef = EnvMapUtils.getSphericalHarmonicsCoefficents(getEnvMap());
+        EnvMapUtils.prepareShCoefs(shCoef);
     }
 
     @Override

+ 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;
+    }
 }

+ 99 - 34
jme3-core/src/main/java/com/jme3/math/FastMath.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
@@ -40,9 +40,10 @@ import java.util.Random;
  * @author Various
  * @version $Id: FastMath.java,v 1.45 2007/08/26 08:44:20 irrisor Exp $
  */
-final public class FastMath {
-    private FastMath() {
-    }
+public final class FastMath {
+
+    private FastMath() {}
+
     /**
      * A "close to zero" double epsilon value for use
      */
@@ -489,10 +490,8 @@ final public class FastMath {
             if (fValue < 1.0f) {
                 return (float) Math.acos(fValue);
             }
-
             return 0.0f;
         }
-
         return PI;
     }
 
@@ -511,10 +510,8 @@ final public class FastMath {
             if (fValue < 1.0f) {
                 return (float) Math.asin(fValue);
             }
-
             return HALF_PI;
         }
-
         return -HALF_PI;
     }
 
@@ -844,35 +841,111 @@ final public class FastMath {
     }
 
     /**
-     * Returns a random float between 0 and 1.
+     * Generates a pseudorandom {@code float} in the range [0.0, 1.0).
      *
-     * @return a random float between 0 (inclusive) and 1 (exclusive)
+     * @return A random {@code float} value.
      */
     public static float nextRandomFloat() {
         return rand.nextFloat();
     }
 
     /**
-     * Returns a random integer between min and max.
+     * Generates a pseudorandom {@code float} in the range [min, max)
      *
-     * @param min the desired minimum value
-     * @param max the desired maximum value
-     * @return a random int between min (inclusive) and max (inclusive)
+     * @param min The lower bound (inclusive).
+     * @param max The upper bound (exclusive).
+     * @return A random {@code float} value within the specified range.
      */
-    public static int nextRandomInt(int min, int max) {
-        return (int) (nextRandomFloat() * (max - min + 1)) + min;
+    public static float nextRandomFloat(float min, float max) {
+        return min + (max - min) * nextRandomFloat();
     }
 
     /**
-     * Choose a pseudo-random, uniformly-distributed integer value from
-     * the shared generator.
+     * Generates a pseudorandom, uniformly-distributed {@code int} value.
      *
-     * @return the next integer value
+     * @return The next pseudorandom {@code int} value.
      */
     public static int nextRandomInt() {
         return rand.nextInt();
     }
 
+    /**
+     * Generates a pseudorandom {@code int} in the range [min, max] (inclusive).
+     *
+     * @param min The lower bound (inclusive).
+     * @param max The upper bound (inclusive).
+     * @return A random {@code int} value within the specified range.
+     */
+    public static int nextRandomInt(int min, int max) {
+        return (int) (nextRandomFloat() * (max - min + 1)) + min;
+    }
+
+    /**
+     * Returns a random point on the surface of a sphere with radius 1.0
+     *
+     * @return A new {@link Vector3f} representing a random point on the surface of the unit sphere.
+     */
+    public static Vector3f onUnitSphere() {
+
+        float u = nextRandomFloat();
+        float v = nextRandomFloat();
+
+        // azimuthal angle: The angle between x-axis in radians [0, 2PI]
+        float theta = FastMath.TWO_PI * u;
+        // polar angle: The angle between z-axis in radians [0, PI]
+        float phi = (float) Math.acos(2f * v - 1f);
+
+        float cosPolar = FastMath.cos(phi);
+        float sinPolar = FastMath.sin(phi);
+        float cosAzim = FastMath.cos(theta);
+        float sinAzim = FastMath.sin(theta);
+
+        return new Vector3f(cosAzim * sinPolar, sinAzim * sinPolar, cosPolar);
+    }
+
+    /**
+     * Returns a random point inside or on a sphere with radius 1.0
+     * This method uses spherical coordinates combined with a cubed-root radius.
+     *
+     * @return A new {@link Vector3f} representing a random point within the unit sphere.
+     */
+    public static Vector3f insideUnitSphere() {
+        float u = nextRandomFloat();
+        // Azimuthal angle [0, 2PI]
+        float theta = FastMath.TWO_PI * nextRandomFloat();
+        // Polar angle [0, PI] for uniform surface distribution
+        float phi = FastMath.acos(2f * nextRandomFloat() - 1f);
+
+        // For uniform distribution within the volume, radius R should be such that R^3 is uniformly distributed.
+        // So, R = cbrt(random_uniform_0_to_1)
+        float radius = (float) Math.cbrt(u);
+
+        float sinPhi = FastMath.sin(phi);
+        float x = radius * sinPhi * FastMath.cos(theta);
+        float y = radius * sinPhi * FastMath.sin(theta);
+        float z = radius * FastMath.cos(phi);
+
+        return new Vector3f(x, y, z);
+    }
+
+    /**
+     * Returns a random point inside or on a circle with radius 1.0.
+     * This method uses polar coordinates combined with a square-root radius.
+     *
+     * @return A new {@link Vector2f} representing a random point within the unit circle.
+     */
+    public static Vector2f insideUnitCircle() {
+        // Angle [0, 2PI]
+        float angle = FastMath.TWO_PI * nextRandomFloat();
+        // For uniform distribution, R^2 is uniform
+        float radius = FastMath.sqrt(nextRandomFloat());
+
+        float x = radius * FastMath.cos(angle);
+        float y = radius * FastMath.sin(angle);
+
+        return new Vector2f(x, y);
+    }
+
     /**
      * Converts a point from Spherical coordinates to Cartesian (using positive
      * Y as up) and stores the results in the store var.
@@ -883,8 +956,7 @@ final public class FastMath {
      * @param store storage for the result (modified if not null)
      * @return the Cartesian coordinates (either store or a new vector)
      */
-    public static Vector3f sphericalToCartesian(Vector3f sphereCoords,
-            Vector3f store) {
+    public static Vector3f sphericalToCartesian(Vector3f sphereCoords, Vector3f store) {
         if (store == null) {
             store = new Vector3f();
         }
@@ -906,8 +978,7 @@ final public class FastMath {
      * @return the Cartesian coordinates: x=distance from origin, y=longitude in
      * radians, z=latitude in radians (either store or a new vector)
      */
-    public static Vector3f cartesianToSpherical(Vector3f cartCoords,
-            Vector3f store) {
+    public static Vector3f cartesianToSpherical(Vector3f cartCoords, Vector3f store) {
         if (store == null) {
             store = new Vector3f();
         }
@@ -936,8 +1007,7 @@ final public class FastMath {
      * @param store storage for the result (modified if not null)
      * @return the Cartesian coordinates (either store or a new vector)
      */
-    public static Vector3f sphericalToCartesianZ(Vector3f sphereCoords,
-            Vector3f store) {
+    public static Vector3f sphericalToCartesianZ(Vector3f sphereCoords, Vector3f store) {
         if (store == null) {
             store = new Vector3f();
         }
@@ -959,8 +1029,7 @@ final public class FastMath {
      * @return the Cartesian coordinates: x=distance from origin, y=latitude in
      * radians, z=longitude in radians (either store or a new vector)
      */
-    public static Vector3f cartesianZToSpherical(Vector3f cartCoords,
-            Vector3f store) {
+    public static Vector3f cartesianZToSpherical(Vector3f cartCoords, Vector3f store) {
         if (store == null) {
             store = new Vector3f();
         }
@@ -982,12 +1051,9 @@ final public class FastMath {
     /**
      * Takes a value and expresses it in terms of min to max.
      *
-     * @param val -
-     *            the angle to normalize (in radians)
-     * @param min
-     *            the lower limit of the range
-     * @param max
-     *            the upper limit of the range
+     * @param val the angle to normalize (in radians)
+     * @param min the lower limit of the range
+     * @param max the upper limit of the range
      * @return the normalized angle (also in radians)
      */
     public static float normalize(float val, float min, float max) {
@@ -1143,5 +1209,4 @@ final public class FastMath {
         return ((n - 1) | (p - 1)) + 1;
     }
 
-
 }

+ 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);
+    }
+
 }

+ 21 - 5
jme3-core/src/main/java/com/jme3/scene/debug/WireSphere.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2020 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -33,13 +33,15 @@ package com.jme3.scene.debug;
 
 import com.jme3.bounding.BoundingSphere;
 import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
 import com.jme3.scene.Mesh;
-import com.jme3.scene.Mesh.Mode;
 import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.VertexBuffer.Format;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.VertexBuffer.Usage;
 import com.jme3.util.BufferUtils;
+
 import java.nio.FloatBuffer;
 import java.nio.ShortBuffer;
 
@@ -151,11 +153,25 @@ public class WireSphere extends Mesh {
     /**
      * Create a WireSphere from a BoundingSphere
      *
-     * @param bsph
-     *     BoundingSphere used to create the WireSphere
-     *
+     * @param bsph BoundingSphere used to create the WireSphere
      */
     public void fromBoundingSphere(BoundingSphere bsph) {
         updatePositions(bsph.getRadius());
     }
+
+    /**
+     * Create a geometry suitable for visualizing the specified bounding sphere.
+     *
+     * @param bsph the bounding sphere (not null)
+     * @return a new Geometry instance in world space
+     */
+    public static Geometry makeGeometry(BoundingSphere bsph) {
+        WireSphere mesh = new WireSphere(bsph.getRadius());
+        Geometry result = new Geometry("bounding sphere", mesh);
+
+        Vector3f center = bsph.getCenter();
+        result.setLocalTranslation(center);
+
+        return result;
+    }
 }

+ 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);

+ 14 - 7
jme3-core/src/main/resources/Common/IBL/IBLKernels.frag

@@ -34,7 +34,7 @@ void brdfKernel(){
         float NdotH = max(H.z, 0.0);
         float VdotH = max(dot(V, H), 0.0);
         if(NdotL > 0.0){
-            float G = GeometrySmith(N, V, L, m_Roughness);
+            float G = GeometrySmith(N, V, L, m_Roughness*m_Roughness);
             float G_Vis = (G * VdotH) / (NdotH * NdotV);
             float Fc = pow(1.0 - VdotH, 5.0);
             A += (1.0 - Fc) * G_Vis;
@@ -75,9 +75,7 @@ void prefilteredEnvKernel(){
     vec3 R = N;
     vec3 V = R;
 
-    // float a2 = m_Roughness;
-    float a2 = m_Roughness * m_Roughness; // jme impl, why?
-    a2 *= a2;
+    float a2 = m_Roughness * m_Roughness; 
 
     const uint SAMPLE_COUNT = 1024u;
     float totalWeight = 0.0;   
@@ -85,16 +83,25 @@ void prefilteredEnvKernel(){
     for(uint i = 0u; i < SAMPLE_COUNT; ++i) {
         vec4 Xi = Hammersley(i, SAMPLE_COUNT);
         vec3 H  = ImportanceSampleGGX(Xi, a2, N);
-        float VoH = dot(V,H);
+        float VoH = max(dot(V, H), 0.0);
         vec3 L  = normalize(2.0 * VoH * H - V);
         float NdotL = max(dot(N, L), 0.0);
         if(NdotL > 0.0) {
+            vec3 sampleColor = texture(m_EnvMap, L).rgb;
+            
+            float luminance = dot(sampleColor, vec3(0.2126, 0.7152, 0.0722));
+            if (luminance > 64.0) { // TODO use average?
+                sampleColor *= 64.0/luminance;
+            }
+            
             // TODO: use mipmap
-            prefilteredColor += texture(m_EnvMap, L).rgb * NdotL;
+            prefilteredColor += sampleColor * NdotL;
             totalWeight      += NdotL;
         }
+
     }
-    prefilteredColor = prefilteredColor / totalWeight;
+
+    if(totalWeight > 0.001) prefilteredColor /= totalWeight;       
     outFragColor = vec4(prefilteredColor, 1.0);
 }  
 

+ 3 - 2
jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.j3md

@@ -147,14 +147,15 @@ MaterialDef PBR Lighting {
          // debug the final value of the selected layer as a color output            
         Int DebugValuesMode
             // Layers:
-            //   0 - albedo (un-shaded)
+            //   0 - albedo (unshaded)
             //   1 - normals
             //   2 - roughness
             //   3 - metallic
             //   4 - ao
-            //   5  - emissive
+            //   5 - emissive
             //   6 - exposure
             //   7 - alpha
+            //   8 - geometryNormals
     }
 
     Technique {

+ 1 - 1
jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.vert

@@ -76,7 +76,7 @@ void main(){
        texCoord2 = inTexCoord2;
     #endif
 
-    wPosition = (g_WorldMatrix * vec4(inPosition, 1.0)).xyz;
+    wPosition = TransformWorld(modelSpacePos).xyz;
     wNormal  = TransformWorldNormal(modelSpaceNorm);
     
     wTangent = vec4(TransformWorldNormal(modelSpaceTan),inTangent.w);

+ 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);
-
     }
 }

+ 62 - 0
jme3-core/src/test/java/com/jme3/audio/AudioFilterTest.java

@@ -0,0 +1,62 @@
+package com.jme3.audio;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.DesktopAssetManager;
+import com.jme3.export.binary.BinaryExporter;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Automated tests for the Filter class.
+ *
+ * @author capdevon
+ */
+public class AudioFilterTest {
+
+    /**
+     * Tests serialization and de-serialization of a {@code LowPassFilter}.
+     */
+    @Test
+    public void testSaveAndLoad_LowPassFilter() {
+        AssetManager assetManager = new DesktopAssetManager(true);
+
+        LowPassFilter f = new LowPassFilter(.5f, .5f);
+        LowPassFilter copy = BinaryExporter.saveAndLoad(assetManager, f);
+
+        float delta = 0.001f;
+        Assert.assertEquals(f.getVolume(), copy.getVolume(), delta);
+        Assert.assertEquals(f.getHighFreqVolume(), copy.getHighFreqVolume(), delta);
+    }
+
+    /**
+     * Tests serialization and de-serialization of a {@code HighPassFilter}.
+     */
+    @Test
+    public void testSaveAndLoad_HighPassFilter() {
+        AssetManager assetManager = new DesktopAssetManager(true);
+
+        HighPassFilter f = new HighPassFilter(.5f, .5f);
+        HighPassFilter copy = BinaryExporter.saveAndLoad(assetManager, f);
+
+        float delta = 0.001f;
+        Assert.assertEquals(f.getVolume(), copy.getVolume(), delta);
+        Assert.assertEquals(f.getLowFreqVolume(), copy.getLowFreqVolume(), delta);
+    }
+
+    /**
+     * Tests serialization and de-serialization of a {@code BandPassFilter}.
+     */
+    @Test
+    public void testSaveAndLoad_BandPassFilter() {
+        AssetManager assetManager = new DesktopAssetManager(true);
+
+        BandPassFilter f = new BandPassFilter(.5f, .5f, .5f);
+        BandPassFilter copy = BinaryExporter.saveAndLoad(assetManager, f);
+
+        float delta = 0.001f;
+        Assert.assertEquals(f.getVolume(), copy.getVolume(), delta);
+        Assert.assertEquals(f.getHighFreqVolume(), copy.getHighFreqVolume(), delta);
+        Assert.assertEquals(f.getLowFreqVolume(), copy.getLowFreqVolume(), delta);
+    }
+
+}

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

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

+ 78 - 0
jme3-core/src/test/java/com/jme3/effect/influencers/ParticleInfluencerTest.java

@@ -0,0 +1,78 @@
+package com.jme3.effect.influencers;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.DesktopAssetManager;
+import com.jme3.export.binary.BinaryExporter;
+import com.jme3.math.Vector3f;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Automated tests for the {@code ParticleInfluencer} class.
+ *
+ * @author capdevon
+ */
+public class ParticleInfluencerTest {
+
+    /**
+     * Tests cloning, serialization and de-serialization of a {@code NewtonianParticleInfluencer}.
+     */
+    @Test
+    public void testNewtonianParticleInfluencer() {
+        AssetManager assetManager = new DesktopAssetManager(true);
+
+        NewtonianParticleInfluencer inf = new NewtonianParticleInfluencer();
+        inf.setNormalVelocity(1);
+        inf.setSurfaceTangentFactor(0.5f);
+        inf.setSurfaceTangentRotation(2.5f);
+        inf.setInitialVelocity(new Vector3f(0, 1, 0));
+        inf.setVelocityVariation(2f);
+
+        NewtonianParticleInfluencer clone = (NewtonianParticleInfluencer) inf.clone();
+        assertEquals(inf, clone);
+        Assert.assertNotSame(inf.temp, clone.temp);
+
+        NewtonianParticleInfluencer copy = BinaryExporter.saveAndLoad(assetManager, inf);
+        assertEquals(inf, copy);
+    }
+
+    private void assertEquals(NewtonianParticleInfluencer inf, NewtonianParticleInfluencer clone) {
+        Assert.assertEquals(inf.getNormalVelocity(), clone.getNormalVelocity(), 0.001f);
+        Assert.assertEquals(inf.getSurfaceTangentFactor(), clone.getSurfaceTangentFactor(), 0.001f);
+        Assert.assertEquals(inf.getSurfaceTangentRotation(), clone.getSurfaceTangentRotation(), 0.001f);
+        Assert.assertEquals(inf.getInitialVelocity(), clone.getInitialVelocity());
+        Assert.assertEquals(inf.getVelocityVariation(), clone.getVelocityVariation(), 0.001f);
+    }
+
+    /**
+     * Tests cloning, serialization and de-serialization of a {@code RadialParticleInfluencer}.
+     */
+    @Test
+    public void testRadialParticleInfluencer() {
+        AssetManager assetManager = new DesktopAssetManager(true);
+
+        RadialParticleInfluencer inf = new RadialParticleInfluencer();
+        inf.setHorizontal(true);
+        inf.setOrigin(new Vector3f(0, 1, 0));
+        inf.setRadialVelocity(2f);
+        inf.setInitialVelocity(new Vector3f(0, 1, 0));
+        inf.setVelocityVariation(2f);
+
+        RadialParticleInfluencer clone = (RadialParticleInfluencer) inf.clone();
+        assertEquals(inf, clone);
+        Assert.assertNotSame(inf.temp, clone.temp);
+        Assert.assertNotSame(inf.getOrigin(), clone.getOrigin());
+
+        RadialParticleInfluencer copy = BinaryExporter.saveAndLoad(assetManager, inf);
+        assertEquals(inf, copy);
+    }
+
+    private void assertEquals(RadialParticleInfluencer inf, RadialParticleInfluencer clone) {
+        Assert.assertEquals(inf.isHorizontal(), clone.isHorizontal());
+        Assert.assertEquals(inf.getOrigin(), clone.getOrigin());
+        Assert.assertEquals(inf.getRadialVelocity(), clone.getRadialVelocity(), 0.001f);
+        Assert.assertEquals(inf.getInitialVelocity(), clone.getInitialVelocity());
+        Assert.assertEquals(inf.getVelocityVariation(), clone.getVelocityVariation(), 0.001f);
+    }
+
+}

+ 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());
+    }
+}

+ 60 - 40
jme3-examples/src/main/java/jme3test/audio/TestAmbient.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
@@ -39,50 +39,70 @@ import com.jme3.material.Material;
 import com.jme3.math.ColorRGBA;
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
-import com.jme3.scene.shape.Box;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.shape.Sphere;
 
 public class TestAmbient extends SimpleApplication {
 
-  public static void main(String[] args) {
-    TestAmbient test = new TestAmbient();
-    test.start();
-  }
+    public static void main(String[] args) {
+        TestAmbient test = new TestAmbient();
+        test.start();
+    }
 
-  @Override
-  public void simpleInitApp() {
-    float[] eax = new float[]{15, 38.0f, 0.300f, -1000, -3300, 0,
-      1.49f, 0.54f, 1.00f, -2560, 0.162f, 0.00f, 0.00f,
-      0.00f, -229, 0.088f, 0.00f, 0.00f, 0.00f, 0.125f, 1.000f,
-      0.250f, 0.000f, -5.0f, 5000.0f, 250.0f, 0.00f, 0x3f};
-    Environment env = new Environment(eax);
-    audioRenderer.setEnvironment(env);
+    private final float[] eax = {
+            15, 38.0f, 0.300f, -1000, -3300, 0,
+            1.49f, 0.54f, 1.00f, -2560, 0.162f, 0.00f, 0.00f,
+            0.00f, -229, 0.088f, 0.00f, 0.00f, 0.00f, 0.125f, 1.000f,
+            0.250f, 0.000f, -5.0f, 5000.0f, 250.0f, 0.00f, 0x3f
+    };
 
-    AudioNode waves = new AudioNode(assetManager,
-            "Sound/Environment/Ocean Waves.ogg", DataType.Buffer);
-    waves.setPositional(true);
-    waves.setLocalTranslation(new Vector3f(0, 0,0));
-    waves.setMaxDistance(100);
-    waves.setRefDistance(5);
+    @Override
+    public void simpleInitApp() {
+        configureCamera();
 
-    AudioNode nature = new AudioNode(assetManager,
-            "Sound/Environment/Nature.ogg", DataType.Stream);
-    nature.setPositional(false);
-    nature.setVolume(3);
-    
-    waves.playInstance();
-    nature.play();
-    
-    // just a blue box to mark the spot
-    Box box1 = new Box(.5f, .5f, .5f);
-    Geometry player = new Geometry("Player", box1);
-    Material mat1 = new Material(assetManager,
-            "Common/MatDefs/Misc/Unshaded.j3md");
-    mat1.setColor("Color", ColorRGBA.Blue);
-    player.setMaterial(mat1);
-    rootNode.attachChild(player);
-  }
+        Environment env = new Environment(eax);
+        audioRenderer.setEnvironment(env);
 
-  @Override
-  public void simpleUpdate(float tpf) {
-  }
+        AudioNode waves = new AudioNode(assetManager,
+                "Sound/Environment/Ocean Waves.ogg", DataType.Buffer);
+        waves.setPositional(true);
+        waves.setLooping(true);
+        waves.setReverbEnabled(true);
+        rootNode.attachChild(waves);
+
+        AudioNode nature = new AudioNode(assetManager,
+                "Sound/Environment/Nature.ogg", DataType.Stream);
+        nature.setPositional(false);
+        nature.setLooping(true);
+        nature.setVolume(3);
+        rootNode.attachChild(nature);
+
+        waves.play();
+        nature.play();
+
+        // just a blue sphere to mark the spot
+        Geometry marker = makeShape("Marker", new Sphere(16, 16, 1f), ColorRGBA.Blue);
+        waves.attachChild(marker);
+
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 2), ColorRGBA.Gray);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(25f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(5f));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
+    }
 }

+ 63 - 0
jme3-examples/src/main/java/jme3test/audio/TestAudioDeviceDisconnect.java

@@ -0,0 +1,63 @@
+package jme3test.audio;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.audio.AudioData;
+import com.jme3.audio.AudioNode;
+import com.jme3.input.KeyInput;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.controls.Trigger;
+
+/**
+ * This test demonstrates that destroying and recreating the OpenAL Context
+ * upon device disconnection is not an optimal solution.
+ *
+ * As shown, AudioNode instances playing in a loop cease to play after a device disconnection
+ * and would require explicit restarting.
+ *
+ * This test serves solely to highlight this issue,
+ * which should be addressed with a more robust solution within
+ * the ALAudioRenderer class in a dedicated future pull request on Git.
+ */
+public class TestAudioDeviceDisconnect extends SimpleApplication implements ActionListener {
+
+    public static void main(String[] args) {
+        TestAudioDeviceDisconnect test = new TestAudioDeviceDisconnect();
+        test.start();
+    }
+
+    private AudioNode audioSource;
+
+    @Override
+    public void simpleInitApp() {
+        audioSource = new AudioNode(assetManager,
+                "Sound/Environment/Ocean Waves.ogg", AudioData.DataType.Buffer);
+        audioSource.setName("Waves");
+        audioSource.setLooping(true);
+        rootNode.attachChild(audioSource);
+
+        audioSource.play();
+
+        registerInputMappings();
+    }
+
+    @Override
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (!isPressed) return;
+
+        if (name.equals("play")) {
+            // re-play active sounds
+            audioSource.play();
+        }
+    }
+
+    private void registerInputMappings() {
+        addMapping("play", new KeyTrigger(KeyInput.KEY_SPACE));
+    }
+
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(this, mappingName);
+    }
+
+}

+ 141 - 0
jme3-examples/src/main/java/jme3test/audio/TestAudioDirectional.java

@@ -0,0 +1,141 @@
+package jme3test.audio;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.audio.AudioData;
+import com.jme3.audio.AudioNode;
+import com.jme3.environment.util.BoundingSphereDebug;
+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.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.debug.Arrow;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.shape.Line;
+
+/**
+ * @author capdevon
+ */
+public class TestAudioDirectional extends SimpleApplication implements ActionListener {
+
+    public static void main(String[] args) {
+        TestAudioDirectional app = new TestAudioDirectional();
+        app.start();
+    }
+
+    private AudioNode audioSource;
+    private final Vector3f tempDirection = new Vector3f();
+    private boolean rotationEnabled = true;
+
+    @Override
+    public void simpleInitApp() {
+        configureCamera();
+
+        audioSource = new AudioNode(assetManager,
+                "Sound/Environment/Ocean Waves.ogg", AudioData.DataType.Buffer);
+        audioSource.setLooping(true);
+        audioSource.setPositional(true);
+        audioSource.setMaxDistance(100);
+        audioSource.setRefDistance(5);
+        audioSource.setDirectional(true);
+//        audioSource.setOuterGain(0.2f); // Volume outside the cone is 20% of the inner volume (Not Supported by jME)
+        audioSource.setInnerAngle(30); // 30-degree cone (15 degrees on each side of the direction)
+        audioSource.setOuterAngle(90); // 90-degree cone (45 degrees on each side of the direction)
+        audioSource.play();
+
+        // just a green sphere to mark the spot
+        Geometry sphere =  BoundingSphereDebug.createDebugSphere(assetManager);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", ColorRGBA.Green);
+        sphere.setMaterial(mat);
+        sphere.setLocalScale(0.5f);
+        audioSource.attachChild(sphere);
+
+        float angleIn = audioSource.getInnerAngle() * FastMath.DEG_TO_RAD;
+        float angleOut = audioSource.getOuterAngle() * FastMath.DEG_TO_RAD;
+        Vector3f forwardDir = audioSource.getWorldRotation().mult(Vector3f.UNIT_Z);
+
+        audioSource.attachChild(createFOV(angleIn, 20f));
+        audioSource.attachChild(createFOV(angleOut, 20f));
+        audioSource.attachChild(makeShape("ZAxis", new Arrow(forwardDir.mult(5)), ColorRGBA.Blue));
+        rootNode.attachChild(audioSource);
+
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 2), ColorRGBA.Gray);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+
+        registerInputMappings();
+    }
+
+    @Override
+    public void simpleUpdate(float tpf) {
+        if (rotationEnabled) {
+            // Example: Rotate the audio node
+            audioSource.rotate(0, tpf * 0.5f, 0);
+            audioSource.setDirection(audioSource.getWorldRotation().mult(Vector3f.UNIT_Z, tempDirection));
+        }
+    }
+
+    @Override
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (!isPressed) return;
+
+        if (name.equals("toggleDirectional")) {
+            boolean directional = !audioSource.isDirectional();
+            audioSource.setDirectional(directional);
+            System.out.println("directional: " + directional);
+
+        } else if (name.equals("toggleRotationEnabled")) {
+            rotationEnabled = !rotationEnabled;
+            System.out.println("rotationEnabled: " + rotationEnabled);
+        }
+    }
+
+    private void registerInputMappings() {
+        addMapping("toggleDirectional", new KeyTrigger(KeyInput.KEY_SPACE));
+        addMapping("toggleRotationEnabled", new KeyTrigger(KeyInput.KEY_P));
+    }
+
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(this, mappingName);
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(25f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(new Vector3f(12, 5, 12));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
+    }
+
+    private Spatial createFOV(float angleRad, float extent) {
+        Vector3f origin = new Vector3f();
+        Node node = new Node("Cone");
+        Vector3f sx = dirFromAngle(angleRad/2).scaleAdd(extent, origin);
+        Vector3f dx = dirFromAngle(-angleRad/2).scaleAdd(extent, origin);
+        node.attachChild(makeShape("Line.SX", new Line(origin, sx), ColorRGBA.Red));
+        node.attachChild(makeShape("Line.DX", new Line(origin, dx), ColorRGBA.Red));
+
+        return node;
+    }
+
+    private Vector3f dirFromAngle(float angleRad) {
+        return new Vector3f(FastMath.sin(angleRad), 0, FastMath.cos(angleRad));
+    }
+}

+ 48 - 21
jme3-examples/src/main/java/jme3test/audio/TestDoppler.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
@@ -35,11 +35,16 @@ package jme3test.audio;
 import com.jme3.app.SimpleApplication;
 import com.jme3.audio.AudioData;
 import com.jme3.audio.AudioNode;
-import com.jme3.math.FastMath;
+import com.jme3.font.BitmapText;
+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.debug.Grid;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.scene.shape.Torus;
+
+import java.util.Locale;
 
 /**
  * Test Doppler Effect
@@ -49,23 +54,17 @@ public class TestDoppler extends SimpleApplication {
     private float pos = -5;
     private float vel = 5;
     private AudioNode ufoNode;
+    private BitmapText bmp;
 
-    public static void main(String[] args){
-        TestDoppler test = new TestDoppler();
-        test.start();
+    public static void main(String[] args) {
+        TestDoppler app = new TestDoppler();
+        app.start();
     }
 
     @Override
     public void simpleInitApp() {
-        flyCam.setMoveSpeed(10);
-
-        Torus torus = new Torus(10, 6, 1, 3);
-        Geometry g = new Geometry("Torus Geom", torus);
-        g.rotate(-FastMath.HALF_PI, 0, 0);
-        g.center();
-
-        g.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
-//        rootNode.attachChild(g);
+        configureCamera();
+        bmp = createLabelText(10, 20, "<placeholder>");
 
         ufoNode = new AudioNode(assetManager, "Sound/Effects/Beep.ogg", AudioData.DataType.Buffer);
         ufoNode.setLooping(true);
@@ -73,22 +72,50 @@ public class TestDoppler extends SimpleApplication {
         ufoNode.setRefDistance(1);
         ufoNode.setMaxDistance(100000000);
         ufoNode.setVelocityFromTranslation(true);
-        ufoNode.play();
+        rootNode.attachChild(ufoNode);
 
-        Geometry ball = new Geometry("Beeper", new Sphere(10, 10, 0.1f));
-        ball.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
+        Geometry ball = makeShape("Beeper", new Sphere(10, 10, .5f), ColorRGBA.Red);
         ufoNode.attachChild(ball);
 
-        rootNode.attachChild(ufoNode);
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 2), ColorRGBA.Gray);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+
+        ufoNode.play();
     }
 
+    private void configureCamera() {
+        flyCam.setMoveSpeed(15f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(12));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
 
     @Override
     public void simpleUpdate(float tpf) {
         pos += tpf * vel;
-        if (pos < -10 || pos > 10) {
+        if (pos < -10f || pos > 10f) {
             vel *= -1;
         }
-        ufoNode.setLocalTranslation(new Vector3f(pos, 0, 0));
+        ufoNode.setLocalTranslation(pos, 0f, 0f);
+        bmp.setText(String.format(Locale.ENGLISH, "Audio Position: (%.2f, %.1f, %.1f)", pos, 0f, 0f));
+    }
+
+    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;
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
     }
 }

+ 150 - 14
jme3-examples/src/main/java/jme3test/audio/TestOgg.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
@@ -37,34 +37,170 @@ import com.jme3.audio.AudioData.DataType;
 import com.jme3.audio.AudioNode;
 import com.jme3.audio.AudioSource;
 import com.jme3.audio.LowPassFilter;
+import com.jme3.font.BitmapText;
+import com.jme3.input.KeyInput;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.controls.Trigger;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.shape.Sphere;
 
-public class TestOgg extends SimpleApplication {
+/**
+ *
+ * @author capdevon
+ */
+public class TestOgg extends SimpleApplication implements ActionListener {
 
+    private final StringBuilder sb = new StringBuilder();
+    private int frameCount = 0;
+    private BitmapText bmp;
     private AudioNode audioSource;
+    private float volume = 1.0f;
+    private float pitch = 1.0f;
 
-    public static void main(String[] args){
+    /**
+     * ### Filters ###
+     * Changing a parameter value in the Filter Object after it has been attached to a Source will not
+     * affect the Source. To update the filter(s) used on a Source, an application must update the
+     * parameters of a Filter object and then re-attach it to the Source.
+     */
+    private final LowPassFilter dryFilter = new LowPassFilter(1f, .1f);
+
+    public static void main(String[] args) {
         TestOgg test = new TestOgg();
         test.start();
     }
 
     @Override
-    public void simpleInitApp(){
-        System.out.println("Playing without filter");
+    public void simpleInitApp() {
+        configureCamera();
+
+        bmp = createLabelText(10, 20, "<placeholder>");
+
+        // just a blue sphere to mark the spot
+        Geometry marker = makeShape("Marker", new Sphere(16, 16, 1f), ColorRGBA.Blue);
+        rootNode.attachChild(marker);
+
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 2), ColorRGBA.Gray);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+
         audioSource = new AudioNode(assetManager, "Sound/Effects/Foot steps.ogg", DataType.Buffer);
+        audioSource.setName("Foot steps");
+        audioSource.setLooping(true);
+        audioSource.setVolume(volume);
+        audioSource.setPitch(pitch);
+        audioSource.setMaxDistance(100);
+        audioSource.setRefDistance(5);
         audioSource.play();
+        rootNode.attachChild(audioSource);
+
+        registerInputMappings();
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(25f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(20f));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+
+    @Override
+    public void simpleUpdate(float tpf) {
+        frameCount++;
+        if (frameCount % 10 == 0) {
+            frameCount = 0;
+
+            sb.append("Audio: ").append(audioSource.getName()).append("\n");
+            sb.append(audioSource.getAudioData()).append("\n");
+            sb.append("Looping: ").append(audioSource.isLooping()).append("\n");
+            sb.append("Volume: ").append(String.format("%.2f", audioSource.getVolume())).append("\n");
+            sb.append("Pitch: ").append(String.format("%.2f", audioSource.getPitch())).append("\n");
+            sb.append("Positional: ").append(audioSource.isPositional()).append("\n");
+            sb.append("MaxDistance: ").append(audioSource.getMaxDistance()).append("\n");
+            sb.append("RefDistance: ").append(audioSource.getRefDistance()).append("\n");
+            sb.append("Status: ").append(audioSource.getStatus()).append("\n");
+            sb.append("SourceId: ").append(audioSource.getChannel()).append("\n");
+            sb.append("DryFilter: ").append(audioSource.getDryFilter() != null).append("\n");
+            sb.append("FilterId: ").append(dryFilter.getId()).append("\n");
+
+            bmp.setText(sb.toString());
+            sb.setLength(0);
+        }
     }
 
     @Override
-    public void simpleUpdate(float tpf){
-        if (audioSource.getStatus() != AudioSource.Status.Playing){
-            audioRenderer.deleteAudioData(audioSource.getAudioData());
-
-            System.out.println("Playing with low pass filter");
-            audioSource = new AudioNode(assetManager, "Sound/Effects/Foot steps.ogg", DataType.Buffer);
-            audioSource.setDryFilter(new LowPassFilter(1f, .1f));
-            audioSource.setVolume(3);
-            audioSource.play();
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (!isPressed) return;
+
+        if (name.equals("togglePlayPause")) {
+            if (audioSource.getStatus() != AudioSource.Status.Playing) {
+                audioSource.play();
+            } else {
+                audioSource.stop();
+            }
+        } else if (name.equals("togglePositional")) {
+            boolean positional = audioSource.isPositional();
+            audioSource.setPositional(!positional);
+
+        } else if (name.equals("dryFilter")) {
+            boolean hasFilter = audioSource.getDryFilter() != null;
+            audioSource.setDryFilter(hasFilter ? null : dryFilter);
+
+        } else if (name.equals("Volume+")) {
+            volume = FastMath.clamp(volume + 0.1f, 0, 5f);
+            audioSource.setVolume(volume);
+
+        } else if (name.equals("Volume-")) {
+            volume = FastMath.clamp(volume - 0.1f, 0, 5f);
+            audioSource.setVolume(volume);
+
+        } else if (name.equals("Pitch+")) {
+            pitch = FastMath.clamp(pitch + 0.1f, 0.5f, 2f);
+            audioSource.setPitch(pitch);
+
+        } else if (name.equals("Pitch-")) {
+            pitch = FastMath.clamp(pitch - 0.1f, 0.5f, 2f);
+            audioSource.setPitch(pitch);
         }
     }
 
+    private void registerInputMappings() {
+        addMapping("togglePlayPause", new KeyTrigger(KeyInput.KEY_P));
+        addMapping("togglePositional", new KeyTrigger(KeyInput.KEY_RETURN));
+        addMapping("dryFilter", new KeyTrigger(KeyInput.KEY_SPACE));
+        addMapping("Volume+", new KeyTrigger(KeyInput.KEY_I));
+        addMapping("Volume-", new KeyTrigger(KeyInput.KEY_K));
+        addMapping("Pitch+", new KeyTrigger(KeyInput.KEY_J));
+        addMapping("Pitch-", new KeyTrigger(KeyInput.KEY_L));
+    }
+
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(this, mappingName);
+    }
+
+    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;
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
+    }
 }

+ 136 - 43
jme3-examples/src/main/java/jme3test/audio/TestReverb.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
@@ -35,50 +35,143 @@ import com.jme3.app.SimpleApplication;
 import com.jme3.audio.AudioData;
 import com.jme3.audio.AudioNode;
 import com.jme3.audio.Environment;
+import com.jme3.audio.LowPassFilter;
+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.material.Material;
+import com.jme3.math.ColorRGBA;
 import com.jme3.math.FastMath;
 import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.shape.Sphere;
 
-public class TestReverb extends SimpleApplication {
-
-  private AudioNode audioSource;
-  private float time = 0;
-  private float nextTime = 1;
-
-  public static void main(String[] args) {
-    TestReverb test = new TestReverb();
-    test.start();
-  }
-
-  @Override
-  public void simpleInitApp() {
-    audioSource = new AudioNode(assetManager, "Sound/Effects/Bang.wav",
-            AudioData.DataType.Buffer);
-
-    float[] eax = new float[]{15, 38.0f, 0.300f, -1000, -3300, 0,
-      1.49f, 0.54f, 1.00f, -2560, 0.162f, 0.00f, 0.00f, 0.00f,
-      -229, 0.088f, 0.00f, 0.00f, 0.00f, 0.125f, 1.000f, 0.250f,
-      0.000f, -5.0f, 5000.0f, 250.0f, 0.00f, 0x3f};
-    audioRenderer.setEnvironment(new Environment(eax));
-    Environment env = Environment.Cavern;
-    audioRenderer.setEnvironment(env);
-  }
-
-  @Override
-  public void simpleUpdate(float tpf) {
-    time += tpf;
-
-    if (time > nextTime) {
-      Vector3f v = new Vector3f();
-      v.setX(FastMath.nextRandomFloat());
-      v.setY(FastMath.nextRandomFloat());
-      v.setZ(FastMath.nextRandomFloat());
-      v.multLocal(40, 2, 40);
-      v.subtractLocal(20, 1, 20);
-
-      audioSource.setLocalTranslation(v);
-      audioSource.playInstance();
-      time = 0;
-      nextTime = FastMath.nextRandomFloat() * 2 + 0.5f;
+/**
+ * @author capdevon
+ */
+public class TestReverb extends SimpleApplication implements ActionListener {
+
+    public static void main(String[] args) {
+        TestReverb app = new TestReverb();
+        app.start();
+    }
+
+    private AudioNode audioSource;
+    private float time = 0;
+    private float nextTime = 1;
+
+    /**
+     * ### Effects ###
+     * Changing a parameter value in the Effect Object after it has been attached to the Auxiliary Effect
+     * Slot will not affect the effect in the effect slot. To update the parameters of the effect in the effect
+     * slot, an application must update the parameters of an Effect object and then re-attach it to the
+     * Auxiliary Effect Slot.
+     */
+    private int index = 0;
+    private final Environment[] environments = {
+            Environment.Cavern,
+            Environment.AcousticLab,
+            Environment.Closet,
+            Environment.Dungeon,
+            Environment.Garage
+    };
+
+    @Override
+    public void simpleInitApp() {
+
+        configureCamera();
+
+        // Activate the Environment preset
+        audioRenderer.setEnvironment(environments[index]);
+
+        // Activate 3D audio
+        audioSource = new AudioNode(assetManager, "Sound/Effects/Bang.wav", AudioData.DataType.Buffer);
+        audioSource.setLooping(false);
+        audioSource.setVolume(1.2f);
+        audioSource.setPositional(true);
+        audioSource.setMaxDistance(100);
+        audioSource.setRefDistance(5);
+        audioSource.setReverbEnabled(true);
+        audioSource.setReverbFilter(new LowPassFilter(1f, 1f));
+        rootNode.attachChild(audioSource);
+
+        Geometry marker = makeShape("Marker", new Sphere(16, 16, 1f), ColorRGBA.Red);
+        audioSource.attachChild(marker);
+
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 4), ColorRGBA.Blue);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+
+        registerInputMappings();
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(50f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(50f));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
+    }
+
+    @Override
+    public void simpleUpdate(float tpf) {
+        time += tpf;
+
+        if (time > nextTime) {
+            time = 0;
+            nextTime = FastMath.nextRandomFloat() * 2 + 0.5f;
+
+            Vector3f position = getRandomPosition();
+            audioSource.setLocalTranslation(position);
+            audioSource.playInstance();
+        }
+    }
+
+    private Vector3f getRandomPosition() {
+        float x = FastMath.nextRandomFloat();
+        float y = FastMath.nextRandomFloat();
+        float z = FastMath.nextRandomFloat();
+        Vector3f vec = new Vector3f(x, y, z);
+        vec.multLocal(40, 2, 40);
+        vec.subtractLocal(20, 1, 20);
+        return vec;
+    }
+
+    @Override
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (!isPressed) return;
+
+        if (name.equals("toggleReverbEnabled")) {
+            boolean reverbEnabled = !audioSource.isReverbEnabled();
+            audioSource.setReverbEnabled(reverbEnabled);
+            System.out.println("reverbEnabled: " + reverbEnabled);
+
+        } else if (name.equals("nextEnvironment")) {
+            index = (index + 1) % environments.length;
+            audioRenderer.setEnvironment(environments[index]);
+            System.out.println("Next Environment Index: " + index);
+        }
+    }
+
+    private void registerInputMappings() {
+        addMapping("toggleReverbEnabled", new KeyTrigger(KeyInput.KEY_SPACE));
+        addMapping("nextEnvironment", new KeyTrigger(KeyInput.KEY_N));
+    }
+
+    private void addMapping(String mappingName, Trigger... triggers) {
+        inputManager.addMapping(mappingName, triggers);
+        inputManager.addListener(this, mappingName);
     }
-  }
+
 }

+ 95 - 20
jme3-examples/src/main/java/jme3test/audio/TestWav.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
@@ -33,32 +33,107 @@ package jme3test.audio;
 
 import com.jme3.app.SimpleApplication;
 import com.jme3.audio.AudioData;
+import com.jme3.audio.AudioKey;
 import com.jme3.audio.AudioNode;
 
+/**
+ * @author capdevon
+ */
 public class TestWav extends SimpleApplication {
 
-  private float time = 0;
-  private AudioNode audioSource;
+    private float time = 0;
+    private AudioNode audioSource;
+
+    public static void main(String[] args) {
+        TestWav app = new TestWav();
+        app.start();
+    }
 
-  public static void main(String[] args) {
-    TestWav test = new TestWav();
-    test.start();
-  }
+    @Override
+    public void simpleInitApp() {
+        testMaxNumChannels();
+        testFakeAudio();
+        testPlaySourceInstance();
 
-  @Override
-  public void simpleUpdate(float tpf) {
-    time += tpf;
-    if (time > 1f) {
-      audioSource.playInstance();
-      time = 0;
+        audioSource = createAudioNode("Sound/Effects/Gun.wav", AudioData.DataType.Buffer);
+        audioSource.setName("Gun");
+        audioSource.setPositional(true);
     }
 
-  }
+    @Override
+    public void simpleUpdate(float tpf) {
+        time += tpf;
+        if (time > 1f) {
+            audioSource.playInstance();
+            time = 0;
+        }
+    }
+
+    /**
+     * Creates an {@link AudioNode} for the specified audio file.
+     * This method demonstrates an alternative way to defer the creation
+     * of an AudioNode by explicitly creating and potentially pre-loading
+     * the {@link AudioData} and {@link AudioKey} before instantiating
+     * the AudioNode. This can be useful in scenarios where you want more
+     * control over the asset loading process or when the AudioData and
+     * AudioKey are already available.
+     *
+     * @param filepath The path to the audio file.
+     * @param type     The desired {@link AudioData.DataType} for the audio.
+     * @return A new {@code AudioNode} configured with the loaded audio data.
+     */
+    private AudioNode createAudioNode(String filepath, AudioData.DataType type) {
+        boolean stream = (type == AudioData.DataType.Stream);
+        boolean streamCache = true;
+        AudioKey audioKey = new AudioKey(filepath, stream, streamCache);
+        AudioData data = assetManager.loadAsset(audioKey);
+
+        AudioNode audio = new AudioNode();
+        audio.setAudioData(data, audioKey);
+        return audio;
+    }
+
+    /**
+     * WARNING: No channel available to play instance of AudioNode[status=Stopped, vol=0.1]
+     */
+    private void testMaxNumChannels() {
+        final int MAX_NUM_CHANNELS = 64;
+        for (int i = 0; i < MAX_NUM_CHANNELS + 1; i++) {
+            AudioNode audio = createAudioNode("Sound/Effects/Gun.wav", AudioData.DataType.Buffer);
+            audio.setVolume(0.1f);
+            audio.playInstance();
+        }
+    }
+
+    /**
+     * java.lang.UnsupportedOperationException: Cannot play instances of audio streams. Use play() instead.
+     * 	at com.jme3.audio.openal.ALAudioRenderer.playSourceInstance()
+     */
+    private void testPlaySourceInstance() {
+        try {
+            AudioNode nature = new AudioNode(assetManager,
+                    "Sound/Environment/Nature.ogg", AudioData.DataType.Stream);
+            audioRenderer.playSourceInstance(nature);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+    private void testFakeAudio() {
+        /**
+         * Tests AudioRenderer.playSource() with an
+         * AudioNode lacking AudioData to observe its handling (typically discard).
+         */
+        AudioNode fakeAudio = new AudioNode() {
+            @Override
+            public String toString() {
+                // includes node name for easier identification in log messages.
+                return getName() + " (" + AudioNode.class.getSimpleName() + ")";
+            }
+        };
+        fakeAudio.setName("FakeAudio");
+        audioRenderer.playSource(fakeAudio);
+        audioRenderer.playSourceInstance(fakeAudio);
+    }
 
-  @Override
-  public void simpleInitApp() {
-    audioSource = new AudioNode(assetManager, "Sound/Effects/Gun.wav",
-            AudioData.DataType.Buffer);
-    audioSource.setLooping(false);
-  }
 }

+ 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;
+    }
 }

+ 84 - 0
jme3-examples/src/main/java/jme3test/math/TestRandomPoints.java

@@ -0,0 +1,84 @@
+package jme3test.math;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.debug.Arrow;
+import com.jme3.scene.debug.Grid;
+import com.jme3.scene.debug.WireSphere;
+import com.jme3.scene.shape.Sphere;
+
+/**
+ * @author capdevon
+ */
+public class TestRandomPoints extends SimpleApplication {
+
+    public static void main(String[] args) {
+        TestRandomPoints app = new TestRandomPoints();
+        app.start();
+    }
+
+    private float radius = 5;
+
+    @Override
+    public void simpleInitApp() {
+        configureCamera();
+        viewPort.setBackgroundColor(ColorRGBA.DarkGray);
+
+        Geometry grid = makeShape("DebugGrid", new Grid(21, 21, 2), ColorRGBA.LightGray);
+        grid.center().move(0, 0, 0);
+        rootNode.attachChild(grid);
+
+        Geometry bsphere = makeShape("BoundingSphere", new WireSphere(radius), ColorRGBA.Red);
+        rootNode.attachChild(bsphere);
+
+        for (int i = 0; i < 100; i++) {
+            Vector2f v = FastMath.insideUnitCircle().multLocal(radius);
+            Arrow arrow = new Arrow(Vector3f.UNIT_Y.negate());
+            Geometry geo = makeShape("Arrow." + i, arrow, ColorRGBA.Green);
+            geo.setLocalTranslation(new Vector3f(v.x, 0, v.y));
+            rootNode.attachChild(geo);
+        }
+
+        for (int i = 0; i < 100; i++) {
+            Vector3f v = FastMath.insideUnitSphere().multLocal(radius);
+            Geometry geo = makeShape("Sphere." + i, new Sphere(16, 16, 0.05f), ColorRGBA.Blue);
+            geo.setLocalTranslation(v);
+            rootNode.attachChild(geo);
+        }
+
+        for (int i = 0; i < 100; i++) {
+            Vector3f v = FastMath.onUnitSphere().multLocal(radius);
+            Geometry geo = makeShape("Sphere." + i, new Sphere(16, 16, 0.06f), ColorRGBA.Cyan);
+            geo.setLocalTranslation(v);
+            rootNode.attachChild(geo);
+        }
+
+        for (int i = 0; i < 100; i++) {
+            float value = FastMath.nextRandomFloat(-5, 5);
+            System.out.println(value);
+        }
+    }
+
+    private void configureCamera() {
+        flyCam.setMoveSpeed(15f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(12));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+
+    private Geometry makeShape(String name, Mesh mesh, ColorRGBA color) {
+        Geometry geo = new Geometry(name, mesh);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setColor("Color", color);
+        geo.setMaterial(mat);
+        return geo;
+    }
+
+}

+ 92 - 0
jme3-examples/src/main/java/jme3test/scene/instancing/TestInstanceNodeWithPbr.java

@@ -0,0 +1,92 @@
+package jme3test.scene.instancing;
+
+import java.util.Locale;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.font.BitmapText;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.instancing.InstancedNode;
+import com.jme3.scene.shape.Box;
+
+/**
+ * This test specifically validates the corrected PBR rendering when combined
+ * with instancing, as addressed in issue #2435. 
+ * 
+ * It creates an InstancedNode
+ * with a PBR-materialized Box to ensure the fix in PBRLighting.vert correctly
+ * handles world position calculations for instanced geometry.
+ */
+public class TestInstanceNodeWithPbr extends SimpleApplication {
+
+    public static void main(String[] args) {
+        TestInstanceNodeWithPbr app = new TestInstanceNodeWithPbr();
+        app.start();
+    }
+
+    private BitmapText bmp;
+    private Geometry box;
+    private float pos = -5;
+    private float vel = 5;
+    
+    @Override
+    public void simpleInitApp() {
+        configureCamera();
+        bmp = createLabelText(10, 20, "<placeholder>");
+        
+        InstancedNode instancedNode = new InstancedNode("InstancedNode");
+        rootNode.attachChild(instancedNode);
+
+        Box mesh = new Box(0.5f, 0.5f, 0.5f);
+        box = new Geometry("Box", mesh);
+        Material pbrMaterial = createPbrMaterial(ColorRGBA.Red);
+        box.setMaterial(pbrMaterial);
+
+        instancedNode.attachChild(box);
+        instancedNode.instance();
+
+        DirectionalLight light = new DirectionalLight();
+        light.setDirection(new Vector3f(-1, -2, -3).normalizeLocal());
+        rootNode.addLight(light);
+    }
+
+    private Material createPbrMaterial(ColorRGBA color) {
+        Material mat = new Material(assetManager, "Common/MatDefs/Light/PBRLighting.j3md");
+        mat.setColor("BaseColor", color);
+        mat.setFloat("Roughness", 0.8f);
+        mat.setFloat("Metallic", 0.1f);
+        mat.setBoolean("UseInstancing", true);
+        return mat;
+    }
+    
+    private void configureCamera() {
+        flyCam.setMoveSpeed(15f);
+        flyCam.setDragToRotate(true);
+
+        cam.setLocation(Vector3f.UNIT_XYZ.mult(12));
+        cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+    }
+    
+    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;
+    }
+    
+    @Override
+    public void simpleUpdate(float tpf) {
+        pos += tpf * vel;
+        if (pos < -10f || pos > 10f) {
+            vel *= -1;
+        }
+        box.setLocalTranslation(pos, 0f, 0f);
+        bmp.setText(String.format(Locale.ENGLISH, "BoxPosition: (%.2f, %.1f, %.1f)", pos, 0f, 0f));
+    }
+
+}

+ 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;
                         }
                     });

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

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

+ 2 - 2
jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglCanvas.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
@@ -677,7 +677,7 @@ public class LwjglCanvas extends LwjglWindow implements JmeCanvasContext, Runnab
             sb.append('\n')
               .append(" *  Red Size: ").append(glData.redSize);
             sb.append('\n')
-              .append(" *  Rreen Size: ").append(glData.greenSize);
+              .append(" *  Green Size: ").append(glData.greenSize);
             sb.append('\n')
               .append(" *  Blue Size: ").append(glData.blueSize);
             sb.append('\n')

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

@@ -274,6 +274,13 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
                 }
         );
 
+        if (glfwPlatformSupported(GLFW_PLATFORM_WAYLAND)) {
+            
+            // 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");
         }

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

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

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

@@ -72,7 +72,7 @@ import static org.junit.jupiter.api.Assertions.fail;
  */
 public class TestDriver extends BaseAppState{
 
-    public static final String IMAGES_ARE_DIFFERENT = "Images are different.";
+    public static final String IMAGES_ARE_DIFFERENT = "Images are different. (If you are running the test locally this is expected, images only reproducible on github CI infrastructure)";
 
     public static final String IMAGES_ARE_DIFFERENT_SIZES = "Images are different sizes.";
 
@@ -197,7 +197,7 @@ public class TestDriver extends BaseAppState{
         });
 
         if(imageFiles.isEmpty()){
-            fail("No screenshot found in the temporary directory.");
+            fail("No screenshot found in the temporary directory. Did the application crash?");
         }
         if(imageFiles.size() != framesToTakeScreenshotsOn.size()){
             fail("Not all screenshots were taken, expected " + framesToTakeScreenshotsOn.size() + " but got " + imageFiles.size());
@@ -218,7 +218,7 @@ public class TestDriver extends BaseAppState{
                     try{
                         Path savedImage = saveGeneratedImageToChangedImages(generatedImage, thisFrameBaseImageFileName);
                         attachImage("New image:", thisFrameBaseImageFileName + ".png", savedImage);
-                        String message = "Expected image not found, is this a new test? If so collect the new image from the step artefacts";
+                        String message = "Expected image not found, is this a new test? If so collect the new image from the step artefacts (on github). If running locally you can see them at build/changed-images but those should not be committed";
                         if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
                             failureMessage = message;
                         }

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

@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.animation;
+
+import com.jme3.anim.SkinningControl;
+import com.jme3.anim.util.AnimMigrationUtils;
+import com.jme3.animation.SkeletonControl;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.AmbientLight;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.VertexBuffer;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for JMonkeyEngine issue #2076: software skinning requires vertex
+ * normals.
+ *
+ * <p>If the issue is resolved, 2 copies of the Jaime model will be rendered in the screenshot.
+ *
+ * <p>If the issue is present, then the application will immediately crash,
+ * typically with a {@code NullPointerException}.
+ *
+ * @author Stephen Gold (original test)
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestIssue2076 extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with two Jaime models, one using the old animation system
+     * and one using the new animation system, both with software skinning and no vertex normals.
+     */
+    @Test
+    public void testIssue2076() {
+        screenshotTest(new BaseAppState() {
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Add ambient light
+                AmbientLight ambientLight = new AmbientLight();
+                ambientLight.setColor(new ColorRGBA(1f, 1f, 1f, 1f));
+                rootNode.addLight(ambientLight);
+
+                /*
+                 * The original Jaime model was chosen for testing because it includes
+                 * tangent buffers (needed to trigger issue #2076) and uses the old
+                 * animation system (so it can be easily used to test both systems).
+                 */
+                String assetPath = "Models/Jaime/Jaime.j3o";
+
+                // Test old animation system
+                Node oldJaime = (Node) assetManager.loadModel(assetPath);
+                rootNode.attachChild(oldJaime);
+                oldJaime.setLocalTranslation(-1f, 0f, 0f);
+
+                // Enable software skinning
+                SkeletonControl skeletonControl = oldJaime.getControl(SkeletonControl.class);
+                skeletonControl.setHardwareSkinningPreferred(false);
+
+                // Remove its vertex normals
+                Geometry oldGeometry = (Geometry) oldJaime.getChild(0);
+                Mesh oldMesh = oldGeometry.getMesh();
+                oldMesh.clearBuffer(VertexBuffer.Type.Normal);
+                oldMesh.clearBuffer(VertexBuffer.Type.BindPoseNormal);
+
+                // Test new animation system
+                Node newJaime = (Node) assetManager.loadModel(assetPath);
+                AnimMigrationUtils.migrate(newJaime);
+                rootNode.attachChild(newJaime);
+                newJaime.setLocalTranslation(1f, 0f, 0f);
+
+                // Enable software skinning
+                SkinningControl skinningControl = newJaime.getControl(SkinningControl.class);
+                skinningControl.setHardwareSkinningPreferred(false);
+
+                // Remove its vertex normals
+                Geometry newGeometry = (Geometry) newJaime.getChild(0);
+                Mesh newMesh = newGeometry.getMesh();
+                newMesh.clearBuffer(VertexBuffer.Type.Normal);
+                newMesh.clearBuffer(VertexBuffer.Type.BindPoseNormal);
+
+                // Position the camera to see both models
+                simpleApplication.getCamera().setLocation(new Vector3f(0f, 0f, 5f));
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+            }
+        }).run();
+    }
+}

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

@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.animation;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.cinematic.MotionPath;
+import com.jme3.cinematic.MotionPathListener;
+import com.jme3.cinematic.events.MotionEvent;
+import com.jme3.font.BitmapText;
+import com.jme3.input.ChaseCamera;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.shape.Box;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the MotionPath functionality.
+ * 
+ * <p>This test creates a teapot model that follows a predefined path with several waypoints.
+ * The animation is automatically started and screenshots are taken at frames 10 and 60
+ * to capture the teapot at different positions along the path.
+ *
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestMotionPath extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a teapot following a motion path.
+     */
+    @Test
+    public void testMotionPath() {
+        screenshotTest(new BaseAppState() {
+            private Spatial teapot;
+            private MotionPath path;
+            private MotionEvent motionControl;
+            private BitmapText wayPointsText;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                Node guiNode = simpleApplication.getGuiNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Set camera position
+                app.getCamera().setLocation(new Vector3f(8.4399185f, 11.189463f, 14.267577f));
+
+                // Create the scene
+                createScene(rootNode, assetManager);
+
+                // Create the motion path
+                path = new MotionPath();
+                path.addWayPoint(new Vector3f(10, 3, 0));
+                path.addWayPoint(new Vector3f(10, 3, 10));
+                path.addWayPoint(new Vector3f(-40, 3, 10));
+                path.addWayPoint(new Vector3f(-40, 3, 0));
+                path.addWayPoint(new Vector3f(-40, 8, 0));
+                path.addWayPoint(new Vector3f(10, 8, 0));
+                path.addWayPoint(new Vector3f(10, 8, 10));
+                path.addWayPoint(new Vector3f(15, 8, 10));
+                path.enableDebugShape(assetManager, rootNode);
+
+                // Create the motion event
+                motionControl = new MotionEvent(teapot, path);
+                motionControl.setDirectionType(MotionEvent.Direction.PathAndRotation);
+                motionControl.setRotation(new Quaternion().fromAngleNormalAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y));
+                motionControl.setInitialDuration(10f);
+                motionControl.setSpeed(2f);
+
+                // Create text for waypoint notifications
+                wayPointsText = new BitmapText(assetManager.loadFont("Interface/Fonts/Default.fnt"));
+                wayPointsText.setSize(wayPointsText.getFont().getCharSet().getRenderedSize());
+                guiNode.attachChild(wayPointsText);
+
+                // Add listener for waypoint events
+                path.addListener(new MotionPathListener() {
+                    @Override
+                    public void onWayPointReach(MotionEvent control, int wayPointIndex) {
+                        if (path.getNbWayPoints() == wayPointIndex + 1) {
+                            wayPointsText.setText(control.getSpatial().getName() + " Finished!!! ");
+                        } else {
+                            wayPointsText.setText(control.getSpatial().getName() + " Reached way point " + wayPointIndex);
+                        }
+                        wayPointsText.setLocalTranslation(
+                                (app.getCamera().getWidth() - wayPointsText.getLineWidth()) / 2,
+                                app.getCamera().getHeight(),
+                                0);
+                    }
+                });
+
+                // note that the ChaseCamera is self-initialising, so just creating this object attaches it
+                new ChaseCamera(getApplication().getCamera(), teapot);
+
+                // Start the animation automatically
+                motionControl.play();
+            }
+
+            private void createScene(Node rootNode, AssetManager assetManager) {
+                // Create materials
+                Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
+                mat.setFloat("Shininess", 1f);
+                mat.setBoolean("UseMaterialColors", true);
+                mat.setColor("Ambient", ColorRGBA.Black);
+                mat.setColor("Diffuse", ColorRGBA.DarkGray);
+                mat.setColor("Specular", ColorRGBA.White.mult(0.6f));
+
+                Material matSoil = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
+                matSoil.setBoolean("UseMaterialColors", true);
+                matSoil.setColor("Ambient", ColorRGBA.Black);
+                matSoil.setColor("Diffuse", ColorRGBA.Black);
+                matSoil.setColor("Specular", ColorRGBA.Black);
+
+                // Create teapot
+                teapot = assetManager.loadModel("Models/Teapot/Teapot.obj");
+                teapot.setName("Teapot");
+                teapot.setLocalScale(3);
+                teapot.setMaterial(mat);
+                rootNode.attachChild(teapot);
+
+                // Create ground
+                Geometry soil = new Geometry("soil", new Box(50, 1, 50));
+                soil.setLocalTranslation(0, -1, 0);
+                soil.setMaterial(matSoil);
+                rootNode.attachChild(soil);
+
+                // Add light
+                DirectionalLight light = new DirectionalLight();
+                light.setDirection(new Vector3f(0, -1, 0).normalizeLocal());
+                light.setColor(ColorRGBA.White.mult(1.5f));
+                rootNode.addLight(light);
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+        })
+        .setFramesToTakeScreenshotsOn(10, 60)
+        .run();
+    }
+}

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

@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.light.pbr;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.environment.EnvironmentCamera;
+import com.jme3.environment.FastLightProbeFactory;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.LightProbe;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.ToneMapFilter;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.texture.plugins.ktx.KTXLoader;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * Screenshot tests for PBR lighting.
+ *
+ * @author nehon - original test
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ *
+ */
+public class TestPBRLighting extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("LowRoughness", 0.1f, false),
+            Arguments.of("HighRoughness", 1.0f, false),
+            Arguments.of("DefaultDirectionalLight", 0.5f, false),
+            Arguments.of("UpdatedDirectionalLight", 0.5f, true)
+        );
+    }
+
+    /**
+     * Test PBR lighting with different parameters
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param roughness The roughness value to use
+     * @param updateLight Whether to update the directional light to match camera direction
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRLighting(String testName, float roughness, boolean updateLight, TestInfo testInfo) {
+
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private static final int RESOLUTION = 256;
+
+            private Node modelNode;
+            private int frame = 0;
+
+            @Override
+            protected void initialize(Application app) {
+                Camera cam = app.getCamera();
+                cam.setLocation(new Vector3f(18, 10, 0));
+                cam.lookAt(new Vector3f(0, 0, 0), Vector3f.UNIT_Y);
+
+                AssetManager assetManager = app.getAssetManager();
+                assetManager.registerLoader(KTXLoader.class, "ktx");
+
+                app.getViewPort().setBackgroundColor(ColorRGBA.White);
+
+                modelNode = new Node("modelNode");
+                Geometry model = (Geometry) assetManager.loadModel("Models/Tank/tank.j3o");
+                MikktspaceTangentGenerator.generate(model);
+                modelNode.attachChild(model);
+
+                DirectionalLight dl = new DirectionalLight();
+                dl.setDirection(new Vector3f(-1, -1, -1).normalizeLocal());
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                simpleApp.getRootNode().addLight(dl);
+                dl.setColor(ColorRGBA.White);
+
+                // If we need to update the light direction to match camera
+                if (updateLight) {
+                    dl.setDirection(app.getCamera().getDirection().normalize());
+                }
+
+                simpleApp.getRootNode().attachChild(modelNode);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+                int numSamples = app.getContext().getSettings().getSamples();
+                if (numSamples > 0) {
+                    fpp.setNumSamples(numSamples);
+                }
+
+                fpp.addFilter(new ToneMapFilter(Vector3f.UNIT_XYZ.mult(4.0f)));
+                app.getViewPort().addProcessor(fpp);
+
+                Spatial sky = SkyFactory.createSky(assetManager, "Textures/Sky/Path.hdr", SkyFactory.EnvMapType.EquirectMap);
+                simpleApp.getRootNode().attachChild(sky);
+
+                Material pbrMat = assetManager.loadMaterial("Models/Tank/tank.j3m");
+                pbrMat.setFloat("Roughness", roughness);
+                model.setMaterial(pbrMat);
+
+                // Set up environment camera
+                EnvironmentCamera envCam = new EnvironmentCamera(RESOLUTION, new Vector3f(0, 3f, 0));
+                app.getStateManager().attach(envCam);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+            @Override
+            public void update(float tpf) {
+                frame++;
+
+                if (frame == 2) {
+                    modelNode.removeFromParent();
+                    LightProbe probe;
+
+                    SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                    probe = FastLightProbeFactory.makeProbe(simpleApp.getRenderManager(),
+                                                           simpleApp.getAssetManager(),
+                                                           RESOLUTION,
+                                                           Vector3f.ZERO,
+                                                           1f,
+                                                           1000f,
+                                                           simpleApp.getRootNode());
+
+                    probe.getArea().setRadius(100);
+                    simpleApp.getRootNode().addLight(probe);
+                }
+
+                if (frame > 10 && modelNode.getParent() == null) {
+                    SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                    simpleApp.getRootNode().attachChild(modelNode);
+                }
+            }
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(12)
+          .run();
+    }
+}

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

@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.light.pbr;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.environment.EnvironmentProbeControl;
+import com.jme3.material.Material;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Spatial;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * A simpler PBR example that uses EnvironmentProbeControl to bake the environment
+ *
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+public class TestPBRSimple extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("WithRealtimeBaking", true),
+            Arguments.of("WithoutRealtimeBaking", false)
+        );
+    }
+
+    /**
+     * Test PBR simple with different parameters
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param realtimeBaking Whether to use realtime baking
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRSimple(String testName, boolean realtimeBaking, TestInfo testInfo) {
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private int frame = 0;
+            
+            @Override
+            protected void initialize(Application app) {
+                Camera cam = app.getCamera();
+                cam.setLocation(new Vector3f(18, 10, 0));
+                cam.lookAt(new Vector3f(0, 0, 0), Vector3f.UNIT_Y);
+
+                AssetManager assetManager = app.getAssetManager();
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                
+                // Create the tank model
+                Geometry model = (Geometry) assetManager.loadModel("Models/Tank/tank.j3o");
+                MikktspaceTangentGenerator.generate(model);
+
+                Material pbrMat = assetManager.loadMaterial("Models/Tank/tank.j3m");
+                model.setMaterial(pbrMat);
+                simpleApp.getRootNode().attachChild(model);
+
+                // Create sky
+                Spatial sky = SkyFactory.createSky(assetManager, "Textures/Sky/Path.hdr", SkyFactory.EnvMapType.EquirectMap);
+                simpleApp.getRootNode().attachChild(sky);
+
+                // Create baker control
+                EnvironmentProbeControl envProbe = new EnvironmentProbeControl(assetManager, 256);
+                simpleApp.getRootNode().addControl(envProbe);
+                
+                // Tag the sky, only the tagged spatials will be rendered in the env map
+                envProbe.tag(sky);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+            @Override
+            public void update(float tpf) {
+                if (realtimeBaking) {
+                    frame++;
+                    if (frame == 2) {
+                        SimpleApplication simpleApp = (SimpleApplication) getApplication();
+                        simpleApp.getRootNode().getControl(EnvironmentProbeControl.class).rebake();
+                    }
+                }
+            }
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(10)
+          .run();
+    }
+}

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

@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.material;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.PointLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.shape.Quad;
+import com.jme3.scene.shape.Sphere;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Screenshot test for the SimpleBumps material test.
+ * 
+ * <p>This test creates a quad with a bump map material and a point light that orbits around it.
+ * The light's position is represented by a small red sphere. Screenshots are taken at frames 10 and 60
+ * to capture the light at different positions in its orbit.
+ * 
+ * @author Richard Tingle (screenshot test adaptation)
+ */
+public class TestSimpleBumps extends ScreenshotTestBase {
+
+    /**
+     * This test creates a scene with a bump-mapped quad and an orbiting light.
+     */
+    @Test
+    public void testSimpleBumps() {
+        screenshotTest(new BaseAppState() {
+            private float angle;
+            private PointLight pl;
+            private Spatial lightMdl;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApplication = (SimpleApplication) app;
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                // Create quad with bump map material
+                Quad quadMesh = new Quad(1, 1);
+                Geometry sphere = new Geometry("Rock Ball", quadMesh);
+                Material mat = assetManager.loadMaterial("Textures/BumpMapTest/SimpleBump.j3m");
+                sphere.setMaterial(mat);
+                MikktspaceTangentGenerator.generate(sphere);
+                rootNode.attachChild(sphere);
+
+                // Create light representation
+                lightMdl = new Geometry("Light", new Sphere(10, 10, 0.1f));
+                lightMdl.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
+                rootNode.attachChild(lightMdl);
+
+                // Create point light
+                pl = new PointLight();
+                pl.setColor(ColorRGBA.White);
+                pl.setPosition(new Vector3f(0f, 0f, 4f));
+                rootNode.addLight(pl);
+            }
+
+            @Override
+            protected void cleanup(Application app) {
+            }
+
+            @Override
+            protected void onEnable() {
+            }
+
+            @Override
+            protected void onDisable() {
+            }
+
+            @Override
+            public void update(float tpf) {
+                super.update(tpf);
+
+                angle += tpf * 2f;
+                angle %= FastMath.TWO_PI;
+
+                pl.setPosition(new Vector3f(FastMath.cos(angle) * 4f, 0.5f, FastMath.sin(angle) * 4f));
+                lightMdl.setLocalTranslation(pl.getPosition());
+            }
+        })
+        .setFramesToTakeScreenshotsOn(10, 60)
+        .run();
+    }
+}

+ 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();
+    }
+}

+ 132 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/scene/instancing/TestInstanceNodeWithPbr.java

@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.scene.instancing;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.font.BitmapText;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.instancing.InstancedNode;
+import com.jme3.scene.shape.Box;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+import java.util.Locale;
+
+/**
+ * This test specifically validates the corrected PBR rendering when combined
+ * with instancing, as addressed in issue #2435.
+ *
+ * <p>
+ * It creates an InstancedNode with a PBR-materialized Box to ensure the fix in 
+ * PBRLighting.vert correctly handles world position calculations for instanced geometry.
+ * </p>
+ *
+ * @author Ryan McDonough - original test
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+public class TestInstanceNodeWithPbr extends ScreenshotTestBase {
+
+    @Test
+    public void testInstanceNodeWithPbr() {
+        screenshotTest(
+            new BaseAppState() {
+                private Geometry box;
+                private float pos = -5;
+                private float vel = 50;
+                private BitmapText bmp;
+
+                @Override
+                protected void initialize(Application app) {
+                    SimpleApplication simpleApp = (SimpleApplication) app;
+
+                    app.getCamera().setLocation(Vector3f.UNIT_XYZ.mult(12));
+                    app.getCamera().lookAt(Vector3f.ZERO, Vector3f.UNIT_Y);
+
+                    bmp = new BitmapText(app.getAssetManager().loadFont("Interface/Fonts/Default.fnt"));
+                    bmp.setText("<placeholder>");
+                    bmp.setLocalTranslation(10, app.getContext().getSettings().getHeight() - 20, 0);
+                    bmp.setColor(ColorRGBA.Red);
+                    simpleApp.getGuiNode().attachChild(bmp);
+
+                    InstancedNode instancedNode = new InstancedNode("InstancedNode");
+                    simpleApp.getRootNode().attachChild(instancedNode);
+
+                    Box mesh = new Box(0.5f, 0.5f, 0.5f);
+                    box = new Geometry("Box", mesh);
+                    Material pbrMaterial = createPbrMaterial(app, ColorRGBA.Red);
+                    box.setMaterial(pbrMaterial);
+
+                    instancedNode.attachChild(box);
+                    instancedNode.instance();
+
+                    DirectionalLight light = new DirectionalLight();
+                    light.setDirection(new Vector3f(-1, -2, -3).normalizeLocal());
+                    simpleApp.getRootNode().addLight(light);
+                }
+
+                private Material createPbrMaterial(Application app, ColorRGBA color) {
+                    Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Light/PBRLighting.j3md");
+                    mat.setColor("BaseColor", color);
+                    mat.setFloat("Roughness", 0.8f);
+                    mat.setFloat("Metallic", 0.1f);
+                    mat.setBoolean("UseInstancing", true);
+                    return mat;
+                }
+
+                @Override
+                public void update(float tpf) {
+                    pos += tpf * vel;
+                    box.setLocalTranslation(pos, 0f, 0f);
+
+                    bmp.setText(String.format(Locale.ENGLISH, "BoxPosition: (%.2f, %.1f, %.1f)", pos, 0f, 0f));
+                }
+
+                @Override
+                protected void cleanup(Application app) {}
+
+                @Override
+                protected void onEnable() {}
+
+                @Override
+                protected void onDisable() { }
+            }
+        )
+        .setFramesToTakeScreenshotsOn(1, 10)
+        .run();
+    }
+}

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

@@ -0,0 +1,291 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.terrain;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.asset.TextureKey;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.LightProbe;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.terrain.geomipmap.TerrainLodControl;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Texture;
+import com.jme3.texture.Texture.WrapMode;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+/**
+ * This test uses 'PBRTerrain.j3md' to create a terrain Material for PBR.
+ *
+ * Upon running the app, the user should see a mountainous, terrain-based
+ * landscape with some grassy areas, some snowy areas, and some tiled roads and
+ * gravel paths weaving between the valleys. Snow should be slightly
+ * shiny/reflective, and marble texture should be even shinier. If you would
+ * like to know what each texture is supposed to look like, you can find the
+ * textures used for this test case located in jme3-testdata.
+ *
+ * Uses assets from CC0Textures.com, licensed under CC0 1.0 Universal. For more
+ * information on the textures this test case uses, view the license.txt file
+ * located in the jme3-testdata directory where these textures are located:
+ * jme3-testdata/src/main/resources/Textures/Terrain/PBR
+ *
+ * @author yaRnMcDonuts (Original manual test)
+ * @author Richard Tingle (aka richtea) - screenshot test adaptation
+ */
+@SuppressWarnings("FieldCanBeLocal")
+public class TestPBRTerrain extends ScreenshotTestBase {
+
+    private static Stream<Arguments> testParameters() {
+        return Stream.of(
+            Arguments.of("FinalRender", 0),
+            Arguments.of("NormalMap", 1),
+            Arguments.of("RoughnessMap", 2),
+            Arguments.of("MetallicMap", 3),
+            Arguments.of("GeometryNormals", 8)
+        );
+    }
+
+    /**
+     * Test PBR terrain with different debug modes
+     * 
+     * @param testName The name of the test (used for screenshot filename)
+     * @param debugMode The debug mode to use
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("testParameters")
+    public void testPBRTerrain(String testName, int debugMode, TestInfo testInfo) {
+
+        if(!testInfo.getTestClass().isPresent() || !testInfo.getTestMethod().isPresent()) {
+            throw new RuntimeException("Test preconditions not met");
+        }
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + "_" + testName;
+
+        screenshotTest(new BaseAppState() {
+            private TerrainQuad terrain;
+            private Material matTerrain;
+
+            private final int terrainSize = 512;
+            private final int patchSize = 256;
+            private final float dirtScale = 24;
+            private final float darkRockScale = 24;
+            private final float snowScale = 64;
+            private final float tileRoadScale = 64;
+            private final float grassScale = 24;
+            private final float marbleScale = 64;
+            private final float gravelScale = 64;
+
+            @Override
+            protected void initialize(Application app) {
+                SimpleApplication simpleApp = (SimpleApplication) app;
+                AssetManager assetManager = app.getAssetManager();
+
+                setUpTerrain(simpleApp, assetManager);
+                setUpTerrainMaterial(assetManager);
+                setUpLights(simpleApp, assetManager);
+                setUpCamera(app);
+
+                // Set debug mode
+                matTerrain.setInt("DebugValuesMode", debugMode);
+            }
+
+            private void setUpTerrainMaterial(AssetManager assetManager) {
+                // PBR terrain matdef
+                matTerrain = new Material(assetManager, "Common/MatDefs/Terrain/PBRTerrain.j3md");
+
+                matTerrain.setBoolean("useTriPlanarMapping", false);
+
+                // ALPHA map (for splat textures)
+                matTerrain.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alpha1.png"));
+                matTerrain.setTexture("AlphaMap_1", assetManager.loadTexture("Textures/Terrain/splat/alpha2.png"));
+
+                // DIRT texture
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                dirt.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_0", dirt);
+                matTerrain.setFloat("AlbedoMap_0_scale", dirtScale);
+                matTerrain.setFloat("Roughness_0", 1);
+                matTerrain.setFloat("Metallic_0", 0);
+
+                // DARK ROCK texture
+                Texture darkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Color.png");
+                darkRock.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_1", darkRock);
+                matTerrain.setFloat("AlbedoMap_1_scale", darkRockScale);
+                matTerrain.setFloat("Roughness_1", 0.92f);
+                matTerrain.setFloat("Metallic_1", 0.02f);
+
+                // SNOW texture
+                Texture snow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Color.png");
+                snow.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_2", snow);
+                matTerrain.setFloat("AlbedoMap_2_scale", snowScale);
+                matTerrain.setFloat("Roughness_2", 0.55f);
+                matTerrain.setFloat("Metallic_2", 0.12f);
+
+                // TILES texture
+                Texture tiles = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Color.png");
+                tiles.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_3", tiles);
+                matTerrain.setFloat("AlbedoMap_3_scale", tileRoadScale);
+                matTerrain.setFloat("Roughness_3", 0.87f);
+                matTerrain.setFloat("Metallic_3", 0.08f);
+
+                // GRASS texture
+                Texture grass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Color.png");
+                grass.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_4", grass);
+                matTerrain.setFloat("AlbedoMap_4_scale", grassScale);
+                matTerrain.setFloat("Roughness_4", 1);
+                matTerrain.setFloat("Metallic_4", 0);
+
+                // MARBLE texture
+                Texture marble = assetManager.loadTexture("Textures/Terrain/PBR/Marble013_1K_Color.png");
+                marble.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_5", marble);
+                matTerrain.setFloat("AlbedoMap_5_scale", marbleScale);
+                matTerrain.setFloat("Roughness_5", 0.06f);
+                matTerrain.setFloat("Metallic_5", 0.8f);
+
+                // Gravel texture
+                Texture gravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Color.png");
+                gravel.setWrap(WrapMode.Repeat);
+                matTerrain.setTexture("AlbedoMap_6", gravel);
+                matTerrain.setFloat("AlbedoMap_6_scale", gravelScale);
+                matTerrain.setFloat("Roughness_6", 0.9f);
+                matTerrain.setFloat("Metallic_6", 0.07f);
+
+                // NORMAL MAPS
+                Texture normalMapDirt = assetManager.loadTexture("Textures/Terrain/PBR/Ground036_1K_Normal.png");
+                normalMapDirt.setWrap(WrapMode.Repeat);
+
+                Texture normalMapDarkRock = assetManager.loadTexture("Textures/Terrain/PBR/Rock035_1K_Normal.png");
+                normalMapDarkRock.setWrap(WrapMode.Repeat);
+
+                Texture normalMapSnow = assetManager.loadTexture("Textures/Terrain/PBR/Snow006_1K_Normal.png");
+                normalMapSnow.setWrap(WrapMode.Repeat);
+
+                Texture normalMapGravel = assetManager.loadTexture("Textures/Terrain/PBR/Gravel015_1K_Normal.png");
+                normalMapGravel.setWrap(WrapMode.Repeat);
+
+                Texture normalMapGrass = assetManager.loadTexture("Textures/Terrain/PBR/Ground037_1K_Normal.png");
+                normalMapGrass.setWrap(WrapMode.Repeat);
+
+                Texture normalMapTiles = assetManager.loadTexture("Textures/Terrain/PBR/Tiles083_1K_Normal.png");
+                normalMapTiles.setWrap(WrapMode.Repeat);
+
+                matTerrain.setTexture("NormalMap_0", normalMapDirt);
+                matTerrain.setTexture("NormalMap_1", normalMapDarkRock);
+                matTerrain.setTexture("NormalMap_2", normalMapSnow);
+                matTerrain.setTexture("NormalMap_3", normalMapTiles);
+                matTerrain.setTexture("NormalMap_4", normalMapGrass);
+                matTerrain.setTexture("NormalMap_6", normalMapGravel);
+
+                terrain.setMaterial(matTerrain);
+            }
+
+            private void setUpTerrain(SimpleApplication simpleApp, AssetManager assetManager) {
+                // HEIGHTMAP image (for the terrain heightmap)
+                TextureKey hmKey = new TextureKey("Textures/Terrain/splat/mountains512.png", false);
+                Texture heightMapImage = assetManager.loadTexture(hmKey);
+
+                // CREATE HEIGHTMAP
+                AbstractHeightMap heightmap;
+                try {
+                    heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.3f);
+                    heightmap.load();
+                    heightmap.smooth(0.9f, 1);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+
+                terrain = new TerrainQuad("terrain", patchSize + 1, terrainSize + 1, heightmap.getHeightMap());
+                TerrainLodControl control = new TerrainLodControl(terrain, getApplication().getCamera());
+                control.setLodCalculator(new DistanceLodCalculator(patchSize + 1, 2.7f)); // patch size, and a multiplier
+                terrain.addControl(control);
+                terrain.setMaterial(matTerrain);
+                terrain.setLocalTranslation(0, -100, 0);
+                terrain.setLocalScale(1f, 1f, 1f);
+                simpleApp.getRootNode().attachChild(terrain);
+            }
+
+            private void setUpLights(SimpleApplication simpleApp, AssetManager assetManager) {
+                LightProbe probe = (LightProbe) assetManager.loadAsset("Scenes/LightProbes/quarry_Probe.j3o");
+
+                probe.setAreaType(LightProbe.AreaType.Spherical);
+                probe.getArea().setRadius(2000);
+                probe.getArea().setCenter(new Vector3f(0, 0, 0));
+                simpleApp.getRootNode().addLight(probe);
+
+                DirectionalLight directionalLight = new DirectionalLight();
+                directionalLight.setDirection((new Vector3f(-0.3f, -0.5f, -0.3f)).normalize());
+                directionalLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(directionalLight);
+
+                AmbientLight ambientLight = new AmbientLight();
+                ambientLight.setColor(ColorRGBA.White);
+                simpleApp.getRootNode().addLight(ambientLight);
+            }
+
+            private void setUpCamera(Application app) {
+                app.getCamera().setLocation(new Vector3f(0, 10, -10));
+                app.getCamera().lookAtDirection(new Vector3f(0, -1.5f, -1).normalizeLocal(), Vector3f.UNIT_Y);
+            }
+
+            @Override
+            protected void cleanup(Application app) {}
+
+            @Override
+            protected void onEnable() {}
+
+            @Override
+            protected void onDisable() {}
+
+        }).setBaseImageFileName(imageName)
+          .setFramesToTakeScreenshotsOn(5)
+          .run();
+    }
+}

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

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

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


Some files were not shown because too many files changed in this diff